Commit Graph
1988 Commits
Author SHA1 Message Date
Codeman maintainer 23fae0c5af chore: version packages
Codex plan usage in the header chip (#346), a visible inline rename in
the session sidebar (#345), and the install.sh Tailscale re-run fix plus
the README network-access prompt description.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
codeman@1.23.2
2026-08-28 00:25:32 +02:00
Ark0N e4699159e9 Merge pull request #346 from JackStuart/codex/show-codex-usage-limits
feat(web): show Codex plan usage in header
2026-08-28 00:14:54 +02:00
Ark0N da085f5f7f Merge pull request #345 from fibr/fix/sidebar-inline-rename
fix(ui): show inline rename text in session sidebar
2026-08-28 00:14:48 +02:00
Codeman maintainer 23e32b22d5 docs(readme): describe the actual three-way network-access prompt
The installer bullet still described a two-way choice with 0.0.0.0 as "the
default", which predates the Tailscale option. The prompt has offered three
choices for a while (Tailscale / any device on your network / this machine
only), and the default is computed from what is already on the machine rather
than being fixed at 0.0.0.0.

Now states all three options, that the Tailscale one is a loopback bind
fronted by `tailscale serve` with the tailnet as the login, and how the
highlighted default is chosen. Line 220 already documented the Tailscale
option correctly; this was the only stale spot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 00:02:33 +02:00
Codeman maintainer 7dfb4acf24 fix(install): offer Tailscale setup on re-run instead of losing it to a failed build
The network-access prompt, where Tailscale serve is configured, runs AFTER
the build step. A build failure therefore exits before the question is ever
asked, and a user who then finishes the build by hand (rather than re-running
install.sh) ends up with a healthy loopback-only Codeman, a connected
Tailscale, and no serve mapping — with nothing anywhere pointing at
`install.sh tailscale`, the command that fixes it. Reported from a fresh
Ubuntu 24 install that died on the node-pty compile.

- maybe_offer_tailscale_repair(): on the update/re-run path, detect exactly
  that state (loopback bind + tailscale Running + no serve mapping fronting
  Codeman) and offer the retrofit. Silent for a deliberate non-loopback bind,
  silent once a mapping exists, silent when tailscale is absent, and prints
  the command instead of prompting when non-interactive. Returns 0 even when
  setup fails so it can never abort an update.
- print_security_notice(): the loopback branch now names
  `install.sh tailscale` when Tailscale is installed on the box, rather than
  the generic "tailscale serve / cloudflared tunnel" advice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 18:29:15 +02:00
Codeman maintainer d3f851a5e5 chore: version packages
install.sh installs a build toolchain on Linux (node-pty has no Linux
prebuild, so a stock Ubuntu 24 server died inside node-gyp with
"not found: make"), plus review hardening for #339: the write-queue
reset paths now release the one-chunk-in-flight gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
codeman@1.23.1
2026-08-26 18:04:44 +02:00
Ark0N 5f8d4de443 Merge pull request #340 from aakhter/pr/cod-341-file-viewer-search
feat(file-viewer): COD-341 search the full workspace
2026-08-26 18:03:21 +02:00
Ark0N 00b32ad2b8 Merge pull request #339 from dignfei/fix/terminal-live-write-backpressure
fix(terminal): bound live xterm backpressure
2026-08-26 18:03:14 +02:00
Jack Stuart b00ab3ceea feat(web): show Codex plan usage in header 2026-08-26 18:43:52 +08:00
Sergei Lupashin 134e200aec fix(ui): show inline rename text in session sidebar 2026-08-25 19:41:48 +02:00
Codeman maintainer a51563ce1f chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
codeman@1.23.0
2026-08-25 19:14:41 +02:00
Ark0N ca5fe1ab3e Merge pull request #338 from Ark0N/feat/vertical-rail-detailed-rows
Vertical tab rail: detailed rows (created / working / status), plus a rename-cancel fix
2026-08-25 19:12:57 +02:00
Ark0N 9cd10afdc9 Merge pull request #341 from Ark0N/feat/deepseek-agent-workers
Spawn and drive DeepSeek Harness workers from the codeman agent skill
2026-08-25 19:12:45 +02:00
Ark0N 975705ad87 Merge pull request #337 from Ark0N/feat/deepseek-harness
feat(deepseek): add DeepSeek Harness (dsh) as a ninth CLI run mode
2026-08-25 19:12:01 +02:00
Codeman maintainer 93a1042bb3 Merge remote-tracking branch 'origin/feat/deepseek-harness' into feat/deepseek-agent-workers
# Conflicts:
#	CLAUDE.md
2026-08-25 19:02:34 +02:00
Codeman maintainer a628737d1f fix(deepseek): review-driven hardening across the harness integration
Fifteen review findings on the dsh mode, the serious ones first:

- Multi-user: DEEPSEEK_BASE_URL joins the owner-clamped env keys.
  _configureDeepSeek() forwards the SERVER's own DEEPSEEK_API_KEY into
  every dsh pane and applyEnvOverrides() lands after it, so a non-granted
  owner who could redirect the base URL would have the operator's key sent
  as a bearer credential to a host of their choosing.
- Wait registry: until=stop/blocked is refused on docker and remote-SSH
  dsh sessions (new deepSeekBridgeUnreachable fact in sessionHookOptions).
  The HERDR triple is set via LOCAL tmux setenv, which crosses neither
  docker exec nor ssh, so such a session can never post a hook event and
  the wait burned its whole timeout on every turn.
- Approvals: a dsh item is an ALERT, not an answerable card. The answer
  route refuses (the '1'/Esc keystrokes are Claude-dialog-shaped and the
  option parser cannot read a third-party TUI's frames, so an answer was a
  blind keystroke into a foreign composer), and the push notification
  carries no Approve/Deny actions for dsh sessions.
- Status shim (v3): --seq is forwarded and the server drops stale retried
  reports inside a 60s window (the TUI retries with backoff, so a retried
  'working' could land after 'blocked' and resolve an approval whose
  dialog was still on screen); 4xx responses exit 0 instead of retrying,
  so one misconfigured session cannot feed the auth rate-limit bucket
  until the hook endpoint 429s for the whole instance.
- Web-UI server: concurrent starts are serialized through a lock (two
  racing POSTs used to pick the same port and orphan the winner), and the
  readiness poll / timeout paths only clear or stop the singleton while it
  is still theirs. First click actually opens the tab now
  (refreshWebviews, not the nonexistent loadWebviews). DELETE
  /api/deepseek/web requires the privileged grant in multi-user mode.
- Cron: deepseek jobs run the same two-part launch gate as the HTTP
  create paths (impl moved into the resolver so all three share it) and no
  longer stamp a Claude default model on the session.
- Parity sweeps: quick-start's docker branch rejects deepSeekConfig like
  the remote branch; the Ralph auto-enable list gained deepseek;
  HookEventType gained agent_working; the phone overview run menu filters
  managed webview records like the desktop menu.
- install.sh: the dsh identity probe closes stdin (under curl|bash a
  child that reads stdin eats the rest of the script), bounds the exec
  with timeout where available, and is memoized to one scan per install.
- Welcome screen: .welcome-btn-deepseek styled in the #4d6bfe brand
  identity (it rendered as an unstyled UA-grey button); stale markup
  comment about the web shortcut rewritten; clamp docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:01:29 +02:00
Codeman maintainer 015b865f56 fix(deepseek): review fixes for the transcript reader — docker/remote gate, poll memo, honest pairing docs
Four review findings on the worker-transcript feature:

- Docker and remote-SSH dsh sessions now keep the pane segmenter: their
  transcripts live in the container's / remote host's own ~/.dsh, which
  the local reader can never see, so the transcript path returned
  'nothing said yet' forever and an agent polling such a worker starved
  on an answer that existed. Gated on !session.docker && !session.remote
  (statically pinned) and documented in the integration guide.

- last-response reads are memoized on (path, mtime, size, blocks): the
  skill's last_text polls once per second, and each poll decompressed and
  reparsed the whole file on the event loop even when nothing had been
  appended. An unchanged poll now costs one stat.

- The pairing ladder's comment claimed /new is served by step 2; in truth
  the boot-window transcript wins for as long as it exists (deliberately:
  preferring newest-eligible would hand a worker its busier sibling's
  reply). The comment now states the real tradeoff instead of the
  aspirational one. Same for decodeZstdFrames' 'skipped' wording — a
  corrupt frame truncates the decode there, which is the safe behavior.

- stripReasoningPrefix no longer runs on user prompt text, so a prompt
  containing a literal </think> renders whole in blocks view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 18:30:41 +02:00
Codeman maintainer 33f77c4680 fix(tabs): review fixes for the detailed rail — width-dialog default, compact wrap pass, rich-aware resets
Three review findings on the detailed-rows feature, all in its edge cases:

- The App Settings width select consulted the handheld defaults blob
  (tabRailWidth: 256) BEFORE the rich-aware default, which the renderer
  never reads — so a tablet's unsized rich rail rendered 320 while the
  dialog said 256, and a routine Save persisted the 256 (below the 288px
  tight threshold, permanently). The chain now mirrors
  applyTabRailWidth()'s actual resolution.

- _setTabRailWidth() re-rendered on a compact flip but never re-ran
  applyTabWrapSettings(), the one owner of the folder line, whose railRich
  input reads the compact class this function just toggled. A rich rail
  dragged below 240px kept emitting folder rows — persistently, for a
  stored width < 240, since the boot wrap pass runs before the class is
  first applied. The wrap pass now re-runs on the flip, with exactly one
  render either way.

- Both reset affordances (handle dblclick, Enter on the handle) reset to
  the hardcoded 256 even on a rich rail, landing it below the tight
  threshold; both now resolve the rich-aware default (320), via a new
  optional defaultWidth input on resolveTabRailKeyboardWidth().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 18:26:07 +02:00
Codeman maintainer 6261b6f655 feat(skill): spawn and drive DeepSeek Harness workers
The agent skill could spawn a worker in any mode, but it could only
DRIVE a claude one: every other CLI has neither a real end-of-turn
signal nor an answer to read, so the recipes route them through output
markers.

dsh has both halves now -- its harness reports idle/working/blocked to
Codeman, and the previous commit reads its transcript -- so it joins
claude as a mode the four verbs work on unchanged. `spawn_workers alpha
beta:deepseek` is a mixed fleet in one call, and `sendwait` / `last_text`
/ `delete_session` need no per-mode variant.

Preamble 1.20.0 (SKILL.md's §0 heredoc regenerated from it):

- `spawn_worker` grows a deepseek branch that gates on the harness
  composer. ⚠️ Readiness there is NOT the stop signal: the harness
  reports idle at BOOT ~300 ms before its composer paints (measured
  2.26 s vs 2.56 s after spawn), so a send-and-wait fired straight after
  quick-start resolves on the boot edge, reports a turn that never ran,
  and strands the prompt in a pane not yet taking input. Waiting for the
  composer also spends that edge, since signals are edge-triggered.
- `spawn_workers` takes `name[:mode]`, so a mixed fleet stays one
  concurrent call. Case names still have to be unique -- the mode never
  disambiguates two workers that would share a directory.
- `sendwait` asks for `wait:"stop,exit"` instead of the `wait:true`
  default set. That set also carries `idle`, which for an external CLI is
  inferred from output stabilization: on a dsh worker whose TUI repaints
  rarely, the re-wait resolved in 0 ms with `signal:"idle"` on a turn
  with three minutes left to run. It also makes a wrong mode loud -- the
  modes that cannot deliver `stop` answer 400 before writing anything,
  instead of resolving on a flap.
- The self-heal resend carries `delivered:true` forward. The resend is a
  tagged duplicate, so the server truthfully reports `delivered:false`
  about a write it skipped, and §1's cleanup then read a completed turn
  as an undelivered one and kept a finished worker forever.
- dsh workers spawn with the permission posture the Run button sends,
  because the harness default still asks and a worker parked on an
  approval row cannot finish a fan-out. The multi-user clamp still
  applies.

Docs: a worked dsh flow in recipes.md, readiness and the signal rules in
verbs.md, and the corrections this makes necessary -- `stop`/`blocked`
are no longer claude-only, and `last-response` is no longer permanently
empty for deepseek. The integration guide gains a section on reading a
session back and driving one as a worker; its web-UI section was also
stale (that server moved out of a shell session).

The static guard that keeps those lists from naming some external CLIs but
not others is extended rather than exempted: it now knows the three real
classes inside that family (no transcript, no hook signals, and the
positive twin -- the modes whose answers can be read), with the hook class
derived from `hooksAvailableForMode()` so the predicate and the prose
cannot drift apart. Any other partial list still fails, and a new backend
belongs to none of the classes until someone says so.
2026-08-25 04:17:48 +02:00
Codeman maintainer d1bc0c517d feat(deepseek): read dsh session transcripts for last-response
`GET /api/sessions/:id/last-response` is how an agent (and the Response
Viewer) reads what a worker said. DeepSeek was falling through to the
pane segmenter with the other external CLIs, which for this mode is not
merely coarse but wrong: dsh-TUI paints a full-screen splash, so a
`last-response` call on a fresh dsh session answered with its ASCII-art
logo -- and anything polling for a worker's first reply reads that as a
reply.

dsh does not belong in that group. It writes a structured JSONL
transcript per session, so read it. Four things in that file shaped the
reader, all measured against real transcripts on disk:

1. dsh appends ONE ZSTD FRAME PER WRITE, and Node's zlib zstd decoder
   (one-shot and streaming alike) stops at the first frame end: a real
   56-line transcript decoded as 1 line / 158 bytes -- the session header
   alone, i.e. a silent truncation that reads as "nothing said yet"
   forever. `zstdFrameRanges()` walks frame and block headers to find
   exact boundaries; splitting on the 4-byte magic would corrupt
   everything after a magic sequence occurring inside compressed data.
   zstd is resolved at RUNTIME because it landed in Node 22.15 while the
   project floor is 22.0, so an older Node keeps the pane behaviour.
2. Every turn also records a plugin-sourced `user/message` (the runtime
   context snapshot), which must not render as the user's own words.
3. A turn that ends in an error carries the provider's message; it is
   surfaced as `Turn error: …` (and a non-error early stop as
   `Turn ended: …`) rather than as an empty string, which an agent reads
   as "still thinking" through fifteen polls.
4. Reply text is assembled per (turn, step): a finalized message wins and
   the streamed deltas fill in only for a step that never finalized, so a
   partial answer is readable mid-turn and never doubled. "Finalized" is
   tracked as a set of steps rather than as non-empty text, because a
   step whose whole reply was reasoning strips to '' at the `</think>`
   boundary and would otherwise resurrect the raw deltas in its place.

Session-to-transcript pairing is by the transcript's own header `cwd`
plus a boot window against the session's createdAt, never by
reproducing dsh's directory mangling (already two forms on disk) and
never by newest-mtime alone -- mtime alone handed a freshly spawned
worker its predecessor's answer in the same case directory.

An empty result still wins over the pane; only a Node that cannot decode
zstd falls back to it.
2026-08-25 04:14:26 +02:00
Codeman maintainer c30dfaf0e7 fix(deepseek): run the web UI server in the background, not in a shell tab
Clicking "DeepSeek web UI..." opened two tabs: the web tab asked for, and a
shell tab running the server next to it. The shell was deliberate - the server
lived in an ordinary session so it was visible, scrollable, killable and died
with its tab, and nothing new had to supervise a long-lived HTTP server. That
reasoning was sound and the result was still wrong in use: opening a dashboard
should open one tab, and after the first launch the terminal is pure noise.

The server moves to a background child process owned by a new
`src/deepseek-web-server.ts`, behind `POST /api/deepseek/web`. What the session
gave away for free is now explicit, which is most of the module:

- Exactly one server. A second click reuses the running one instead of racing
  it for a port; the session flow could not do this at all, because two clicks
  were simply two sessions.
- Restarted when the requested authority changes. `--trusted-host` fences dsh's
  own /api against the browser authority, and a Codeman reachable at both
  loopback and a tailnet name has two. Reusing a server fenced for the other
  origin renders a page whose every call 403s, which reads as a broken
  dashboard rather than a misconfigured one, so a mismatch restarts instead.
- Killed on shutdown. The child is detached so its whole plugin tree can be
  signalled at once, which also means it would outlive Codeman and hold its
  port against the next start - the exact EADDRINUSE this feature already got
  wrong once.
- Boot output captured and returned. With no shell tab there is nowhere else
  for a stack trace to land, so a failed spawn reports its own tail.

The endpoint is fenced at the same bar as the profile installer and for the
same reason: booting a dsh profile executes the plugin code in it, so this is a
privileged action even though it reads as "open a page". `authority` comes from
the client (`location.host`) because only the browser knows which origin is in
play, and it is regex-confined at the schema boundary - defence in depth behind
the argv-array spawn, admitting host:port in the shapes a browser authority can
take and nothing readable as a second argument.

`GET /api/deepseek/web-port` is gone; port selection moved into the supervisor,
which is the thing that knows whether a server is already running. The two
client-side probe helpers went with it, since the server now owns the wait.

Verified over the tailnet authority end to end: no session is created (session
count unchanged, one tab), the server runs on 3081 beside the user's own dsh
web on 3080, status reports the tailnet authority, and the proxied dashboard
renders with zero 4xx. Full gate green (6148 passed, +6).
2026-08-25 03:08:15 +02:00
Codeman maintainer 15ae5f5d81 fix(deepseek): make the web-UI shortcut pick a free port, verify it, and trust its frame
The `Run > DeepSeek web UI...` shortcut failed three ways at once against a real
install, and the three are independent.

1. It hardcoded `--port 3080`. That is dsh web's OWN default, which makes it
   precisely the port a DeepSeek user is most likely to be serving on already,
   so the launch died with EADDRINUSE against the user's own server. The port
   now comes from `GET /api/deepseek/web-port`, which walks 3080..3119 for a
   free loopback port by BINDING it (a connect probe cannot tell "free" from
   "listening but not answering yet").

2. It opened the tab unconditionally. The crashed server left a saved dashboard
   pointing at nothing, with the failure only visible in a shell tab nobody had
   a reason to look at. The launch now polls the existing webview probe until
   the URL answers, and on timeout reports the error naming the shell tab
   instead of persisting a dead dashboard.

3. The saved tab was untrusted, so the frame was sandboxed without
   `allow-same-origin` and the dashboard was broken twice over: the dsh
   client-runtime reads `localStorage` while loading its plugins and died there
   ("the document is sandboxed and lacks the 'allow-same-origin' flag"), and an
   opaque-origin frame sends `Origin: null`, so dsh's own trust fence 403'd
   every `/api` call no matter which authority `--trusted-host` named. Passing
   `location.host` only means anything once the frame actually carries that
   origin, so `--trusted-host` had never once done its job. The managed tab is
   now created `trusted: true`.

   That trade is real and deliberate: a trusted proxied frame is same-origin
   with Codeman and can reach Codeman's API. It is defensible only because this
   dashboard is an agent harness Codeman just started itself, on loopback, which
   can already run code as the user. It is not a precedent for trusting
   third-party dashboards, which is why it is set at this one call site rather
   than defaulted.

Separately, the shortcut listed its own dashboard twice: once as the menu entry
that starts it and once as the row that entry had written on the previous click.
Webviews now carry an optional `managed` marker, managed rows are filtered out
of the saved-dashboard list, and a relaunch repoints the existing row rather
than stacking one dead dashboard per restart (which the per-launch port would
otherwise guarantee). `managed` is declared in the schema because a plain
`z.object` strips undeclared keys, so an undeclared marker would never survive
the round trip.

`DEEPSEEK_WEB_PORT` is gone from constants.js; its doc comment asserted that a
hand-started `dsh web` and the shortcut "land on the same place and share one
saved tab", which is the bug stated as a feature.

Verified on a real install with the user's own `dsh web` holding 3080: the
shortcut takes 3081, the server answers, exactly one DeepSeek entry shows in the
run menu, and the proxied dashboard renders its workspaces and completes its own
API calls (the previously-403'd `api/settings.describe` now succeeds). Full gate
green (6142 passed), typecheck/lint/format/public-assets clean.
2026-08-25 02:39:57 +02:00
Codeman maintainer 14de2b7012 fix(tabs): keep the created stamp reachable on a tight rail, and do not skip the first render
Two review nits on the vertical rail's detailed rows.

1. The tab-rail-tight rule (below 288px) hides `.tab-meta-created`, and its
   comment claimed the value "survives in the row's title attribute either way".
   It did not: the only title carrying it lived ON that element, and a
   `display: none` element has no hover target, so the created stamp was not
   shrunk but gone with no way to ask for it. Rather than just correcting the
   comment, `_sidebarRichMetaHTML()` now puts BOTH absolute stamps on the
   `.tab-meta` line itself, so the pill and the gaps around the stamps remain as
   hover targets. An item's own title still wins where the item is visible.

2. applyTabOrientation() decided whether applyTabWrapSettings() had already
   re-rendered by comparing `_tallTabsEnabled` before and after. That reads an
   UNDEFINED previous value as "it rendered", but applyTabWrapSettings()
   deliberately renders nothing on its first call ever (it only establishes the
   baseline: `prevTallTabs !== undefined && prevTallTabs !== showFolder`). So on
   a first call that also flips the folder row, neither function rendered and the
   rows stayed stale. Reachable when the pre-paint script throws and leaves the
   layout attributes on their catch-branch fallbacks for applyTabOrientation() to
   correct. The guard now mirrors applyTabWrapSettings()'s own condition.

Both new tests were run against the unfixed code first and fail there, which is
the only thing that makes them regression tests. (The third, "does not render
twice", passes either way by design: it pins that fix 2 did not introduce a
double rebuild.)

Verified in a real browser against a live server with two sessions, driving the
narrowing through _setTabRailWidth() the way the resize drag does: at the 320
default the row reads "CREATED 2m ago · IDLE <1m" with the created element
displayed; at 256 the tight class is on, the created element computes to
display:none, the visible text drops to "IDLE <1m", and the meta line's title
still reads "First created: ...". At 220 the compact threshold drops rich rows
entirely. Screenshots confirm no truncation artifacts in either state.

Full gate green (6104 passed), typecheck, lint, format, frontend-syntax and
public-assets all clean.
2026-08-24 22:58:28 +02:00
Codeman maintainer cdceede33d fix(deepseek): atomic shim write, honest attribution comment, name-fallback profile classifier
The three smaller review nits, plus the first real test coverage for the status
shim (it had none: it is emitted as a STRING, so tsc never sees it).

1. The shim was written with a plain writeFileSync. The TUI can be exec'ing that
   exact path while an upgraded Codeman refreshes it, and a reader catching a
   half-written file gets a syntax error, exits non-zero, and is retried four
   times per state change for a file that will never parse. Now temp + rename
   (atomic within the directory), with the temp chmod'ed before the rename since
   writeFileSync's mode only applies on create, and removed if the write throws.
   SHIM_VERSION bumped to 2, because SHIM_SOURCE changed and an existing v1 shim
   would otherwise keep matching the embedded marker and never be refreshed.

2. The pane-id comment claimed the ambient env "cannot be spoofed by an argument
   the agent itself could influence". The agent runs IN that pane and can invoke
   the shim with CODEMAN_SESSION_ID unset and any argv it likes. It buys nothing
   it did not already have (the hook-secret file is readable from the same pane,
   so it can POST /api/hook-event directly), but the comment read like a security
   boundary. Rewritten to say what the preference actually buys: correct
   attribution when a TUI mangles or re-uses the pane argument. Accidents, not
   adversaries.

3. classifyProfile() folded the directory name into the same haystack as the
   bundles, but only the TUI arm could match a bare name, so a stock profile
   whose package.json has no dsh.profile.bundles (hand-edited, older layout,
   mid-install) classified as `unknown` -> launchable -> eligible as the DEFAULT
   pick, which is exactly the pane-dies-on-arrival failure the two-part
   availability gate exists to prevent. The stock names are now a LAST-resort
   fallback consulted after the bundle patterns, so real bundle evidence still
   wins over a name the user chose. The loose `tui` arm gained word boundaries:
   it decides which profile boots by default, and matching the middle of
   `intuition` is not a rule anyone could predict.

New test/deepseek-status-shim.test.ts runs the generated script the way the
harness does -- real node process, real argv, real env, real listener -- and
covers the exit-code contract that makes the retry behaviour safe: mapped states
post and exit 0, an unknown verb or unmapped state exits 0 WITHOUT posting (a
non-zero there would be four HTTP requests per state change forever), a rejecting
server or an unreachable one exits non-zero so the caller retries, the hook secret
is read at execution time, and `node --check` parses the file (a template-literal
typo in SHIM_SOURCE is invisible to tsc).

Trap worth recording, hit while writing it: the tests must spawn the shim
ASYNCHRONOUSLY. The listener lives in the test process, so spawnSync blocks the
event loop that has to accept the connection, the shim waits out its own 1500ms
socket timeout and exits 1, and it reads exactly like a broken shim (measured:
Socket._onTimeout in its --trace-exit output, server logging nothing).

Verified: full gate green (6142 passed, +10), typecheck/lint/format clean.
2026-08-24 18:00:52 +02:00
Codeman maintainer 2034719d61 fix(deepseek): close the env-var clamp hole, bound the profile install, make the hook gate per-session
Three review findings on the DeepSeek Harness mode, plus one the third exposed.

1. The multi-user clamp was bypassable by a sibling field on the same request.
   clampExternalCliBypassForOwner() clamps deepSeekConfig.permissionMode, but
   DSH_* is an allowlisted envOverrides prefix and applyEnvOverrides() runs AFTER
   _configureDeepSeek(), so a non-granted owner sending
   envOverrides.DSH_PERMISSION_MODE landed last and won. Measured on an isolated
   instance: a session created with permissionMode "read-only" and that override
   ran with DSH_PERMISSION_MODE=danger-full-access in its pane.

   Every other CLI's bypass is a command-line flag reachable only through the
   per-CLI config, which is why the config clamp alone is the whole gate for
   them. clampEnvOverridesForOwner() adds the env-var half: for a non-granted
   owner it DROPS DSH_PERMISSION_MODE and DSH_HOME (dropping falls through to
   what _configureDeepSeek() exports, i.e. the clamped value). DSH_HOME is on
   that list because it aims the launcher at a profile tree whose plugin code
   runs at boot, before any approval row can apply. Verified end to end in real
   multi-user mode: a non-granted user sending both now gets workspace-write and
   no DSH_HOME, while an unrelated DSH_TELEMETRY_MODE passes through untouched.

2. POST /api/deepseek/install-profile could hang forever. spawn's own `timeout`
   signals only the direct child, and a plugin install fans out into
   package-manager children that keep the inherited stdio pipes open, so `close`
   never fires and the held-open request leaks with no route-level deadline.
   Reproduced: with a 1.5s built-in timeout the promise was still unsettled after
   6s and both fan-out children were alive. Now detached: true plus negative-pid
   SIGTERM/SIGKILL, the same escalation runGit() uses for the same reason, with a
   last-resort reap for a grandchild that escaped the group. Same probe after the
   change: close fires, direct child and both grandchildren dead.

3. hooksAvailableForMode() promised more than a dsh session can deliver.
   deepSeekConfig.statusReporting: false disarms the HERDR_* export, and that
   triple is the only reason a dsh session posts hook events, so `until=stop` was
   accepted and then blocked for the caller's whole timeout: the exact
   infinite-wait-dressed-as-a-timeout the predicate exists to prevent. It now
   takes HookCapabilityOptions and every call site passes sessionHookOptions(),
   with the deepseek arm reading `!== false` so a forgotten one degrades to the
   old behaviour. The refusal names the setting rather than saying "no Claude
   Code hooks", which would send the caller hunting a bug that is really a
   setting they chose. Profile conformance stays unknowable at request time and
   is documented as such. The stale "True for `claude` and nothing else" docblock
   is corrected.

4. Exposed by (3): hooksAvailableForMode() was doing double duty as "is this a
   claude session". Read My Mind (POST /api/sessions/:id/readmymind) and intent
   capture read Claude's own transcript, and adding deepseek silently widened
   both to a mode that has none. They compare mode === 'claude' directly now, and
   a static check pins them there.

Verified: full CI gate green (6132 passed), typecheck/lint/format clean, and the
wait-signal gating exercised against a live server with a real dsh 0.1.1-rc.2 --
bridge off plus explicit until=stop is a 400 naming the setting, bridge off with
no `until` still 200s on idle/exit, bridge on accepts stop.
2026-08-24 16:01:02 +02:00
d fei 7c62b16e5f fix(terminal): bound live xterm backpressure 2026-08-24 19:06:50 +08:00
Aamer Akhter d15d979a33 fix: COD-341 correct changeset package name 2026-08-23 22:55:10 -04:00
Aamer Akhter 858b15e3f5 chore: COD-341 add File Viewer search release note 2026-08-23 22:46:58 -04:00
Codeman maintainer b330f1d9e8 feat(tabs): give the vertical rail the home screen's per-session detail
The vertical tab rail (tabOrientation 'vertical') listed names and nothing
else, while the rich sidebar and both home screens already answered the
question a docked column exists to answer: which of these sessions wants me
next, and how long has it been like that. The rail is a docked column too, so
it now draws the same row.

- New per-device setting tabRailDetail ('rich' | 'simple', default rich),
  App Settings -> Appearance -> Tabs, in SettingsUpdateSchema + displayKeys and
  stamped as data-tab-rail-detail by the pre-paint script, so a detailed rail
  does not flash through simple rows on every load.
- ONE gate for both vertical surfaces: isRichTabRows() =
  isSessionSidebarRich() || isTabRailRich(). The row model, the markup and the
  20s in-place clock are the existing rich-sidebar ones, classified by
  _mobileOverviewState/_mobileOverviewSince, so the rail, the sidebar, the
  desktop home rail and the phone overview cannot disagree about what
  "working" means or which stamp measures it.
- Detail rides on its OWN attribute, exactly as the sidebar's does, so every
  existing [data-tab-orientation='vertical'] rule keeps matching both variants
  untouched. A flip of detail ALONE still forces a full render (the stamps line
  is emitted by the row template, not toggled by CSS) and re-runs
  applyTabWrapSettings(), which owns the folder line and is now rail-aware.
- CSS: every rich paint rule gains a rail twin as a COMMA-GROUPED selector,
  never :is() - an :is() list takes its most specific argument, which would
  lift the sidebar arm from (0,3,1) to the rail's (0,5,1) and let these rules
  outrank things they never used to.
- Width is why there are thresholds. At 256px the stamps line ellipsizes
  mid-word, the same reason the rich sidebar is 300px, so a rail that has never
  been sized defaults to 320 (RICH_DEFAULT_WIDTH, the existing Wide preset,
  which also keeps the settings select on a named choice). A width the user has
  chosen is never overridden: below 288px the created stamp is dropped rather
  than truncated (tab-rail-tight, CSS only) and below 240px the rows go back to
  simple (tab-rail-compact, which re-renders).
- The rich clock is armed and disarmed by applyTabOrientation() as well as
  applySessionListLayout(); a leaked interval would rewrite stamps in a list
  that no longer has any.

Also fixes a data-loss bug in the inline tab rename that predates the rail and
reproduces in every layout, header strip included: Escape set the input to ''
and blurred it, and the blur handler commits - so cancelling a rename PUT an
empty name, and the tab fell back to its folder label (measured against a live
server: ["rail-alpha","","rail-gamma"]). Escape now calls cancelRename(), which
invalidates the edit so the blur that follows the input's removal is a no-op.

Tests: rail-detail gate, the three ways it turns back off (simple, compact,
horizontal), sidebar-wins, render-on-detail-flip and the plumbing/CSS guards in
test/session-list-layout.test.ts; the rename cancel in test/inline-rename.test.ts
(browser suite), pinned by running it against the old code first. Verified live
against a real server on an isolated instance: detailed/simple/compact/header/
sidebar variants, click-select, the ... menu, inline rename, Alt+N, the in-place
stamp tick and a full settings-picker round-trip including reload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 04:45:02 +02:00
Aamer Akhter c14171b534 fix(file-viewer): COD-341 finalize deferred navigation 2026-08-23 22:42:10 -04:00
Aamer Akhter acd9ffedc8 fix(file-viewer): COD-341 complete search transitions 2026-08-23 22:27:26 -04:00
Aamer Akhter 921933775b test(file-viewer): COD-341 execute session lifecycle path 2026-08-23 22:08:29 -04:00
Aamer Akhter f6a1f06633 fix(file-viewer): COD-341 synchronize session lifecycle 2026-08-23 21:58:45 -04:00
Aamer Akhter dab8e6643c fix(file-viewer): COD-341 deduplicate normal tree loads 2026-08-23 21:40:10 -04:00
Codeman maintainer 4cda150493 feat(deepseek): add DeepSeek Harness (dsh) as a ninth CLI run mode
Adds `mode: 'deepseek'` alongside claude/shell/opencode/codex/gemini/
antigravity/pi/grok, plus a shortcut that opens the harness's own browser UI
as a Codeman web tab.

DeepSeek is wired unlike its siblings in three ways, each of which is the
reason for a design decision rather than an accident:

1. The agent is a PROFILE, not the binary. `dsh` is a launcher over
   $DSH_HOME/profiles/<name>, and DeepSeek ships only `web`, `headless` and
   `base` -- the interactive terminal front door is always a third-party
   plugin. So availability is two questions: `isDeepSeekAvailable()` (binary)
   and `isDeepSeekRunnable()` (binary AND a pane-capable profile). The Run
   button gates on the latter, because reporting only the binary would spawn a
   pane that dies on arrival. When the binary is present but no profile is,
   the run menu offers to install one (POST /api/deepseek/install-profile).

2. The permission switch is an env var, not a flag. The harness has no
   command-line permission option; its sandbox/approval rows read
   DSH_PERMISSION_MODE (read-only / workspace-write / danger-full-access).
   Exported via `tmux setenv`, never on the spawn line. Absent = the harness's
   own workspace-write, which still asks, so the multi-user clamp is the
   only-if-sent branch and clamps to workspace-write, never read-only.

3. It is the only non-claude mode that passes hooksAvailableForMode(), and it
   earned that. The terminal front door reports idle/working/blocked to a
   supervising process over a generic env-gated contract; a generated shim
   (deepseek-status-shim.ts) makes Codeman that supervisor and forwards each
   report to /api/hook-event as stop / agent_working / permission_prompt. So a
   dsh session gets definitive respawn triggers, real wait-endpoint signals and
   real Approvals Inbox items instead of output-stabilization guesswork.
   `agent_working` is new (157th SSE constant) and joins
   APPROVAL_RESOLVING_EVENTS so a dialog answered in the terminal clears its
   alert at once.

The resolver needs the strictest identity probe of the family: `dsh` is not
merely a squattable npm name, Debian ships an unrelated `dsh` (dancer's shell),
so `dsh --help` must print the harness's own banner before a candidate is
handed a spawn line.

Model is deliberately not a session field -- it is a composition entry in the
profile's config tree. Env allowlist gains DSH_* and DEEPSEEK_* only; provider
keys named by a settings-file `apiKeyEnv` stay out, which is pi's
34-provider-key problem in a new shape.

Verified live against dsh 0.1.1-rc.2 and @deepseek-harness-tui/dsh-tui: the
status endpoint's two-part answer, the no-profile refusal, the profile
bootstrap, a real session whose pane runs `dsh --profile dsh-tui` with the
permission mode injected via setenv, and the full status bridge -- a
send-and-wait returned signal "stop" from a real turn, and blocked/working
created and cleared an Approvals Inbox item.

Docs: docs/deepseek-integration.md (guide), docs/deepseek-integration-plan.md
(decisions + honest gaps). Tests: test/deepseek-mode.test.ts,
test/deepseek-cli-resolver.test.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 03:37:56 +02:00
Aamer Akhter 3af36f7c34 fix(file-viewer): COD-341 preserve search row layout 2026-08-23 21:23:17 -04:00
Aamer Akhter 49797e37dd fix(file-viewer): COD-341 gate stale search results 2026-08-23 21:09:52 -04:00
Aamer Akhter c614331d60 feat(file-viewer): COD-341 add server-side search 2026-08-23 21:01:39 -04:00
Codeman maintainer 9cfd8e8989 fix(docker): survive xAI installer's own /usr/local/bin/grok symlink
The agent-image grok step copied /root/.grok/bin/grok onto /usr/local/bin/grok
with cp -L. Newer versions of xAI's install.sh already create
/usr/local/bin/grok as a symlink to that same binary, so the copy failed with
'same file' and the --no-cache rebuild died at the grok layer (2026-08-24).
Stage the copy under a temp name, drop whatever the installer left at the
destination, then move into place - correct against both old and new
installers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
codeman@1.22.0
2026-08-24 01:12:38 +02:00
Codeman maintainer 8fe393826b chore: version packages 2026-08-24 01:00:51 +02:00
Codeman maintainer 7a340fe7bc fix(tabs): review-driven hardening for the tab-layout foundation and vertical rail
Post-merge follow-ups from the deep review of #334 and #335, so they ship in
the same release as the features.

Tab-layout foundation (#335):
- PUT /api/session-order drops unknown/foreign ids again instead of 400ing
  the whole write, in both the owner and the admin path (single-user requests
  are the synthetic admin, so that path is the one the browser hits). The
  frontend debounces its reorder push and swallows errors, so a session
  deleted inside the debounce window silently cost the user the entire
  reorder - and the endpoint sits on the stable /api/v1 surface, where the
  pre-layout server merged leniently.
- A failed mux restore no longer locks explicit deletions into 500s for the
  process lifetime: runSessionDeletion and webviewDeleted degrade to
  best-effort without layout coordination, while the automated stale sweep
  (runStaleSessionCleanup) stays fail-closed.
- sse-events doc comment: no 'suppressed' hook event exists; hooks stay 8.
- registerSessionWithLayout resolves its owner through ownerLayoutKey()
  instead of a hardcoded '@single'.

Vertical rail (#334) - all rail-awareness gaps in sidebar-only predicates,
unified behind the new _isVerticalTabList() (sidebar OR rail):
- Drag-reorder read the insertion side from clientX in the rail, so
  before/after was effectively arbitrary on vertical rows; the drag-over
  indicators now draw as top/bottom edges there like the sidebar's.
- The active tab is scrolled into view in the rail (Alt+N/palette selection
  used to leave the row below the fold).
- Floating subagent/ultracode windows anchor to the RIGHT of rail tabs, and
  the connector redraw gates (render tail + strip scroll) cover the rail.
- Server-seeded tabOrientation is applied when the async settings load
  resolves, not only at boot, so a fresh device shows the rail immediately.
- The pre-paint script stamps data-tab-orientation and --tab-rail-width
  (sidebar-wins and solo carve-outs included), removing the flash of the
  header strip on every vertical-mode load.
- The session name font defaults to 12px, the sidebar's historical 0.75rem
  size, so installs that never touch the new slider are not restyled.

Also documents the rail in CLAUDE.md (second #sessionTabs host, mover
ordering, the axis-predicate rule) and gives tab-rail-resize.js its
@dependency/@loadorder header. Full gate green (6093 tests); the excluded
browser suite was run by hand - only the known environmental failures
(opencode/codex binaries) remain, identical to pristine master.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 00:59:48 +02:00
Codeman maintainer f3c615b669 fix(files): give the file preview a working detach button
The button next to the file preview's close icon was Copy Content, whose
overlapping-pages glyph reads as a pop-out control - and for a PDF or any
media/binary preview it was completely dead: those branches never fill
filePreviewContent, so the click hit an empty-content guard and did nothing,
with no feedback.

There is now a real detach button that opens the previewed file in a browser
tab (raw route for PDFs/images/media/text, the server-converted PDF preview
for docx/pptx), severs window.opener by hand so a blocked pop-up stays
detectable, closes the overlay on success (which also stops any playing
media), and disarms on close so it can never open a stale file. The copy
button now toasts 'Nothing to copy in this preview' instead of staying
silent.

Verified live with Playwright against an isolated instance: button visible
and armed on a PDF preview, file-raw answers 200, clicking opens the URL and
tears the overlay down, text previews keep a working copy buffer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 00:59:27 +02:00
Ark0N 82f81d21c4 Merge pull request #333 from Ark0N/feat/grok-mode
feat(grok): add Grok Build (xAI) as a seventh CLI run mode
2026-08-24 00:43:04 +02:00
Codeman maintainer c173ae0264 Merge remote-tracking branch 'origin/master' into worktree-grok-mode
# Conflicts:
#	src/web/public/app.js
2026-08-24 00:32:25 +02:00
Ark0N e9dd55e5fd Merge pull request #334 from aakhter/pr/cod-358-vertical-rail
feat(tabs): add a resizable vertical session rail
2026-08-24 00:14:10 +02:00
Ark0N dd96f252ea Merge pull request #335 from aakhter/pr/cod-359-tab-layout
feat(tabs): add owner-scoped tab layout foundation
2026-08-24 00:12:20 +02:00
Aamer Akhter 74194e4fc0 feat(tabs): COD-359 add owner-scoped tab layouts 2026-08-23 14:46:10 -04:00
Aamer Akhter c45c6c3846 merge upstream master into COD-358 2026-08-23 14:15:07 -04:00
Aamer Akhter 17b141dc25 test(workflows): keep recent-run fixture clock-independent 2026-08-23 14:12:38 -04:00
Codeman maintainer 57f326ab8f docs(grok): fix the mode counts and line refs the seventh mode invalidated
The agent skill's endpoints.md is what other agents read as ground truth, and
three of its facts went stale when grok landed:

- `/api/v1/grok/status` was added to the probe list, but the sentence after it
  still said only Pi's response carries `.data.version`. Grok's carries it for
  the same reason (a squatted binary name), and an agent that trusts the old
  wording has no way to tell a misresolved grok from an absent one.
- the `active-tools` bullet listed grok among the modes it stays empty for, then
  claimed in the same breath that `isExternalCliMode` "lists only those five".
- its three source line refs had all drifted: `isExternalCliMode` is now
  session.ts:174-183 (it was already wrong before this branch), the external-CLI
  early return is session.ts:2261, and TEXT_COMMAND_PATTERN is
  bash-tool-parser.ts:89.

CLAUDE.md and architecture-invariants.md counted modes in their Docker-cases and
Web-tabs paragraphs ("any of the five CLI backends", "never a sixth
SessionMode"). Both numbers were already stale before grok (antigravity and pi
had made it seven) and grok is now in the agent image, so the counts are gone
rather than incremented: the invariant those sentences carry is that Docker and
web tabs are not modes at all, which no number has ever helped state. The two
plan docs keep their original wording, being historical design records.
2026-08-23 19:57:13 +02:00