Skip to content

Commit 500501f

Browse files
Shahinyanmclaude
andcommitted
feat(classifier): TJ_CLASSIFIER_CLI env var for workspace wrappers (v0.2.4)
Users with aimux, nix run, direnv, or similar wrappers cant use the bare 'claude' binary that the CLI classifier hardcodes — auth lives in a profile-scoped credential store (~/.aimux/profiles/<name>/) that plain 'claude -p' cannot see, producing 401 errors. This commit adds TJ_CLASSIFIER_CLI: a whitespace-split command line that lets the user wrap claude however their environment requires: export TJ_CLASSIFIER_CLI="aimux run dt claude" The classifier splits this on spaces, takes the first token as the program, and prepends the rest as base args before its own (-p, --model, etc.). Default 'claude' behavior unchanged when env var is unset. Real-world trigger: aimux user reported 700+ ghost session files in ~/.claude/projects/ from days of failed claude -p invocations. Each invocation creates a session log on disk before failing, so silent failures still pollute the filesystem. Test: classifier_command_with_spaces_runs_wrapper_then_target — uses a temp wrapper script that mimics aimuxs prefix-then-forward semantics. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 96078f1 commit 500501f

10 files changed

Lines changed: 153 additions & 24 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
},
77
"metadata": {
88
"description": "Task Journal — append-only reasoning chain memory for AI-coding tasks",
9-
"version": "0.2.3"
9+
"version": "0.2.4"
1010
},
1111
"plugins": [
1212
{
1313
"name": "task-journal",
1414
"source": "./plugin",
1515
"description": "Append-only journal of AI-coding task reasoning chains. Captures hypotheses, decisions, rejections, evidence — renders compact resume packs so an agent can pick up a 2-week-old task with full context.",
16-
"version": "0.2.3",
16+
"version": "0.2.4",
1717
"author": {
1818
"name": "Digital-Threads"
1919
},

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.2.4] - 2026-05-07
11+
12+
Hotfix: support workspace-orchestrator wrappers (aimux, nix-shell, etc).
13+
14+
### Added
15+
- `TJ_CLASSIFIER_CLI` env var. The CLI classifier (`--backend=cli`)
16+
now splits this on whitespace and uses it as the program + base
17+
arguments before appending the classifier-specific flags. Lets users
18+
with `aimux`, `direnv`, `nix run` and similar wrappers pass through
19+
to their actual `claude` binary without symlinks or PATH gymnastics:
20+
```bash
21+
export TJ_CLASSIFIER_CLI="aimux run dt claude"
22+
```
23+
When unset, defaults to the bare `claude` (previous behavior).
24+
25+
### Fixed
26+
- Auto-capture hooks were silently failing for users on managed Claude
27+
Code installations where the `claude` binary is not directly on PATH
28+
but accessed via a wrapper. The `TJ_CLASSIFIER_CLI` override resolves
29+
this without requiring binary changes to install-hooks.
30+
1031
## [0.2.3] - 2026-05-07
1132

1233
Hotfix release. Re-release of the 0.2.2 fixes plus CI repair —

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ members = [
77
]
88

99
[workspace.package]
10-
version = "0.2.3"
10+
version = "0.2.4"
1111
edition = "2021"
1212
rust-version = "1.83"
1313
license = "MIT"

crates/tj-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ name = "task-journal"
1616
path = "src/main.rs"
1717

1818
[dependencies]
19-
tj-core = { package = "task-journal-core", version = "0.2.3", path = "../tj-core" }
19+
tj-core = { package = "task-journal-core", version = "0.2.4", path = "../tj-core" }
2020
anyhow = { workspace = true }
2121
clap = { workspace = true }
2222
tracing = { workspace = true }

crates/tj-cli/src/tui/chat_view.rs

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,27 @@ pub struct ChatView {
3535
impl ChatView {
3636
pub fn from_session(session: &ParsedSession) -> Self {
3737
let mut messages = Vec::new();
38+
// Collapse tool-only assistant messages. When the assistant
39+
// emits N consecutive turns of pure tool_use without any
40+
// commentary, we accumulate the tool names and attach them to
41+
// the *next* assistant message that does have text (or, if the
42+
// session ends mid-tool-storm, surface a single collapsed
43+
// entry so the activity is not invisible).
44+
let mut pending_tools: Vec<String> = Vec::new();
45+
let mut last_tool_ts: Option<String> = None;
3846

3947
for entry in &session.entries {
4048
match entry {
4149
SessionEntry::User(u) => {
50+
if !pending_tools.is_empty() {
51+
let count = pending_tools.len();
52+
messages.push(ChatMessage {
53+
role: Role::Assistant,
54+
text: format!("({count} tool call(s) — no commentary)"),
55+
timestamp: last_tool_ts.take().unwrap_or_default(),
56+
tools: std::mem::take(&mut pending_tools),
57+
});
58+
}
4259
if let Some(text) = extract_user_text(u) {
4360
let clean = strip_xml_tags(&text);
4461
if !clean.trim().is_empty() {
@@ -55,25 +72,43 @@ impl ChatView {
5572
let texts = extract_assistant_texts(a);
5673
let tools = extract_tool_uses(a);
5774
let tool_names: Vec<String> = tools.iter().map(|(n, _)| n.clone()).collect();
58-
5975
let combined = texts.join("\n");
60-
if !combined.trim().is_empty() || !tool_names.is_empty() {
61-
let display_text = if combined.trim().is_empty() {
62-
format!("[{} tool call(s)]", tool_names.len())
63-
} else {
64-
combined
65-
};
76+
77+
if combined.trim().is_empty() {
78+
// Tool-only turn: accumulate, do not render yet.
79+
if !tool_names.is_empty() {
80+
pending_tools.extend(tool_names);
81+
last_tool_ts = Some(format_ts(&a.timestamp));
82+
}
83+
} else {
84+
// Text turn: flush accumulated tools onto this
85+
// message together with its own.
86+
let mut all_tools = std::mem::take(&mut pending_tools);
87+
last_tool_ts = None;
88+
all_tools.extend(tool_names);
6689
messages.push(ChatMessage {
6790
role: Role::Assistant,
68-
text: display_text,
91+
text: combined,
6992
timestamp: format_ts(&a.timestamp),
70-
tools: tool_names,
93+
tools: all_tools,
7194
});
7295
}
7396
}
7497
_ => {}
7598
}
7699
}
100+
// Trailing tool-only burst: surface as one collapsed entry so
101+
// it's visible the assistant did work, but without polluting
102+
// the timeline with empty turns.
103+
if !pending_tools.is_empty() {
104+
let count = pending_tools.len();
105+
messages.push(ChatMessage {
106+
role: Role::Assistant,
107+
text: format!("({count} tool call(s) — no commentary)"),
108+
timestamp: last_tool_ts.unwrap_or_default(),
109+
tools: pending_tools,
110+
});
111+
}
77112

78113
let title = if let Some(first) = session.first_user_text() {
79114
let clean = strip_xml_tags(&first);

crates/tj-core/src/classifier/cli.rs

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@ use serde::Deserialize;
1313
/// Backend that invokes `claude -p` via subprocess.
1414
///
1515
/// Configuration:
16-
/// - `command`: program name (default `"claude"`); override for tests/dev.
17-
/// - `model`: model alias passed via `--model`. Overridable via the
18-
/// `TJ_CLASSIFIER_MODEL` env var; falls back to `DEFAULT_MODEL` (haiku —
16+
/// - `command`: full command line that produces `claude` invocation;
17+
/// default `"claude"`. May contain spaces to wrap the binary in a
18+
/// workspace orchestrator like `aimux run dt claude` or a Nix
19+
/// shell. Override via `TJ_CLASSIFIER_CLI` env var.
20+
/// - `model`: model alias passed via `--model`. Overridable via
21+
/// `TJ_CLASSIFIER_MODEL`; falls back to `DEFAULT_MODEL` (haiku —
1922
/// cheaper than the user's session model).
2023
pub struct ClaudeCliClassifier {
2124
pub command: String,
@@ -28,7 +31,7 @@ pub const DEFAULT_MODEL: &str = "haiku";
2831
impl Default for ClaudeCliClassifier {
2932
fn default() -> Self {
3033
Self {
31-
command: "claude".into(),
34+
command: std::env::var("TJ_CLASSIFIER_CLI").unwrap_or_else(|_| "claude".into()),
3235
model: std::env::var("TJ_CLASSIFIER_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into()),
3336
}
3437
}
@@ -45,7 +48,17 @@ impl Classifier for ClaudeCliClassifier {
4548
fn classify(&self, input: &ClassifyInput) -> anyhow::Result<ClassifyOutput> {
4649
let prompt = crate::classifier::prompt::build(input);
4750

48-
let output = std::process::Command::new(&self.command)
51+
// Split command on whitespace so users can wrap the binary
52+
// in a workspace orchestrator: `aimux run dt claude`,
53+
// `nix run nixpkgs#claude --`, etc.
54+
let mut parts = self.command.split_whitespace();
55+
let program = parts
56+
.next()
57+
.ok_or_else(|| anyhow!("TJ_CLASSIFIER_CLI is empty"))?;
58+
let base_args: Vec<&str> = parts.collect();
59+
60+
let output = std::process::Command::new(program)
61+
.args(&base_args)
4962
.args([
5063
"-p",
5164
"--model",
@@ -188,4 +201,64 @@ mod tests {
188201
assert!(err.contains("Not logged in"));
189202
assert!(err.contains("claude /login"));
190203
}
204+
205+
#[test]
206+
fn classifier_command_with_spaces_runs_wrapper_then_target() {
207+
// Simulates `aimux run dt claude` style wrappers: a launcher
208+
// script that ignores its first argv, then forwards everything
209+
// else to the real fake-claude. We verify TJ_CLASSIFIER_CLI
210+
// splitting works end-to-end.
211+
let dir = tempfile::TempDir::new().unwrap();
212+
213+
let inner = r#"{"event_type":"finding","task_id_guess":null,"confidence":0.9,"evidence_strength":null,"suggested_text":"x"}"#;
214+
let envelope = serde_json::json!({
215+
"type": "result",
216+
"subtype": "success",
217+
"is_error": false,
218+
"result": inner,
219+
});
220+
let real_fake = fake_claude(dir.path(), &envelope.to_string());
221+
222+
// Wrapper script that takes a "profile" arg and delegates.
223+
#[cfg(unix)]
224+
let wrapper = {
225+
use std::os::unix::fs::PermissionsExt;
226+
let path = dir.path().join("fake-aimux.sh");
227+
// shellcheck-clean: we intentionally drop $1 (profile name)
228+
// and forward $2..$N to the real fake.
229+
let script = format!(
230+
"#!/bin/sh\nshift\nshift\nshift\nexec \"{}\" \"$@\"\n",
231+
real_fake.to_string_lossy()
232+
);
233+
std::fs::write(&path, script).unwrap();
234+
let mut perms = std::fs::metadata(&path).unwrap().permissions();
235+
perms.set_mode(0o755);
236+
std::fs::set_permissions(&path, perms).unwrap();
237+
path
238+
};
239+
#[cfg(windows)]
240+
let wrapper = {
241+
let path = dir.path().join("fake-aimux.cmd");
242+
// Drop %1 %2 %3 (run dt claude) and pass the rest.
243+
let script = format!(
244+
"@echo off\r\ncall \"{}\" %4 %5 %6 %7 %8 %9\r\n",
245+
real_fake.to_string_lossy()
246+
);
247+
std::fs::write(&path, script).unwrap();
248+
path
249+
};
250+
251+
let c = ClaudeCliClassifier {
252+
command: format!("{} run dt claude", wrapper.to_string_lossy()),
253+
model: "haiku".into(),
254+
};
255+
let out = c
256+
.classify(&ClassifyInput {
257+
text: "x".into(),
258+
author_hint: "user".into(),
259+
recent_tasks: vec![],
260+
})
261+
.unwrap();
262+
assert_eq!(out.event_type, EventType::Finding);
263+
}
191264
}

crates/tj-mcp/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ name = "task-journal-mcp"
1616
path = "src/main.rs"
1717

1818
[dependencies]
19-
tj-core = { package = "task-journal-core", version = "0.2.3", path = "../tj-core" }
19+
tj-core = { package = "task-journal-core", version = "0.2.4", path = "../tj-core" }
2020
anyhow = { workspace = true }
2121
tokio = { workspace = true }
2222
tracing = { workspace = true }

plugin/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "task-journal",
3-
"version": "0.2.3",
3+
"version": "0.2.4",
44
"description": "Append-only journal of AI-coding task reasoning chains: hypotheses, decisions, rejections, evidence. Renders compact resume packs so an agent can pick up a 2-week-old task with full context.",
55
"author": {
66
"name": "Mher Shahinyan"

plugin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "task-journal",
3-
"version": "0.2.3",
3+
"version": "0.2.4",
44
"description": "Append-only journal of AI-coding task reasoning chains. Captures hypotheses, decisions, rejections, evidence — renders compact resume packs so an agent can pick up a 2-week-old task with full context.",
55
"author": {
66
"name": "Mher Shahinyan",

0 commit comments

Comments
 (0)