Compare commits

...
Author SHA1 Message Date
Codeman maintainer 8bedbbe793 chore: version packages 2026-11-10 23:59:42 +01:00
Codeman maintainer 84229de575 chore: the 1.50.1 changeset names #801 and #802
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 23:29:23 +01:00
Codeman maintainer 4f2b7dfec9 Merge #802: Codeman and LogoLight take turns on the lid logo (with #801: the logo keeps blinking with the lid closed)
# Conflicts:
#	packages/desktop/README.md
2026-11-10 23:29:23 +01:00
Codeman maintainer eef02ab00a docs(desktop): the lid-closed blink was seen on a real MacBook
The README still said nobody had watched this app blink the logo under a
closed lid. The owner ran release candidate 6 of 1.50.1 on a MacBook Pro
and reported that it works. It also names what the LogoLight handover was
checked against.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 23:27:24 +01:00
Codeman maintainer 43e13e1b5d chore: the 1.50.1 changeset names the lid-closed logo and the LogoLight handover
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 23:11:06 +01:00
Codeman maintainer a032e9829a Merge feat/desktop-logo-loan: the lid logo blinks on with the lid closed, and takes turns with LogoLight
# Conflicts:
#	packages/desktop/README.md
#	packages/desktop/src/main.rs
#	packages/desktop/src/menu.rs
2026-11-10 23:10:51 +01:00
Codeman maintainer 5cd8fa049a feat(desktop): blink the logo while a LogoLight that steps aside runs
LogoLight drives the lid logo with the same DisplayLogicalState fades,
so Codeman used to leave the logo to it whenever it ran: the owner had
LogoLight open and the release candidate never breathed.

LogoLight steps aside instead since its 1.3.0 ("LogoLight steps aside
while Codeman shows its agents on the logo, and takes the logo back
afterwards"). This is Codeman's side of that contract, which lives in the
marker the blinker already keeps for crash recovery:
- it now holds the blinker's process ID;
- it goes down right before the first write rather than when the blink
  starts, so a blink waiting for the display does not pause LogoLight for
  nothing; under a closed lid the first write is telling the logo the lid
  is open, so the marker and the wait come before that;
- with a LogoLight that says LogoLightStepsAsideForCodeman in its
  Info.plist, the blinker waits two seconds after writing it, since
  LogoLight looks every second. A blink that starts within a second of
  the last one's end (work, then a permission) skips the wait: LogoLight
  takes the logo back only after the marker has been gone for 1.5 s.

The key is read from the running LogoLight's bundle by process ID
(proc_pidpath, then the plist file, never NSBundle, which would keep a
stale copy across a LogoLight update). An older LogoLight is still left
the logo, and the warning when the switch is turned on now says to update
it. At launch, a logo a killed child left is put back as before, but with
any LogoLight running the lid state is left to it.

Tests: 229 pass on the Mac mini, and six deliberate breaks of the
handover each fail one. The Info.plist lookup was run against a stand-in
process carrying the Info.plist of the released LogoLight 1.3.1: it reads
"steps aside". Not seen with a real LogoLight on a real logo yet.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 23:10:28 +01:00
Codeman maintainer 979275af1f fix(desktop): blink under a closed lid the way LogoLight 1.3.1 measured it
LogoLight 1.3.1 lights the logo behind a closed lid on a real MacBook kept
awake with no display attached, and its research notes say why the earlier
tries here stayed dark and what must not be written:

- Kept awake like that, the built-in display stays online and
  CGDisplayIsAsleep says 0, so the lid path was never taken ("not tried
  yet"). The lid sensor decides now (in since the last commit).
- DisplayPowerStateEvent is gone. A client's DidSleep keeps the logo dark,
  lid open too, until a DidWake is written; the last commit wrote DidSleep
  when a blink ended, and at launch with a marker and the lid closed.
- After ClamshellUpdate 0, each display in CBDisplayList is told the lid
  is closed again (setProperty:withKey:andDisplay:), as each keyboard is:
  told it is open, True Tone tints an external display warm and dim.
- The logo is told once, when the daemon has handled the lid
  (CBClamshellState, bounded to two seconds of waiting), not at every
  cycle, which would toggle those display modules with every beat. It is
  told again when it is found dark at the start of a beat, or when a rise
  did not move it: three times in a row, then every half minute, with
  "stayed dark" in the menu line.
- Unplugging under the lid tells the logo the lid is closed again.
- "The Mac went to sleep" is only said while the lid is closed.

The keep-awake side needs nothing new: pmset disablesleep holds the Mac at
full wake, which is the state LogoLight measured; an assertion against
system sleep alone gives a dark wake, where the logo stays dark.

221 tests pass on the Mac mini, and 13 deliberate breaks of the lid rules
each fail one. Not seen from this app on a real lid yet.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 23:06:29 +01:00
Codeman maintainer ede99b309c fix(desktop): the lid sensor decides, and every lid close leaves a reason
On RC4 the owner closed the lid with LogoLight not running and the menu
line still said "not tried yet": the child never wrote anything. Three
ways that could happen left no trace: the display list still showing the
built-in display awake under the closed lid (the blink then never took
the lid path), no blink running because no agent was at work, and the
Mac going to sleep at the lid close.

The lid sensor now wins: a closed lid counts as closed whatever the
display list says. While no blink runs and the lid is closed, the line
says why (nothing to show, no agent at work; the logo switched off;
LogoLight has the logo; a refused change). The child notices a sleep as
wall-clock time that passed while Instant stood still, and the line then
says "the Mac went to sleep". With no child at all (switch off, no
server open) the app says nothing watches the lid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:36:50 +01:00
Codeman maintainer c307d63d3b fix(desktop): under a closed lid, just keep blinking, and say what happened
RC3's lid path went dark on the real MacBook without a word: it waited for
CBClamshellState, gave up after two tries, and wrote ClamshellUpdate 1
back on every failed proof, and none of its silent paths left a trace
without a Terminal launch.

Now, while the lid sensor (AppleClamshellState) says closed, the built-in
display is off and the Mac is on its power adapter, the logo is told the
lid is open (and each keyboard that it is closed) at the start of every
cycle, with no tries and no give-up: monotone, so nothing is toggled in
between. With no other display on the display sleeps, which also keeps
the logo dark, so if the first rise does not move it the logo is told the
display woke (DisplayPowerStateEvent 2); DidSleep is written back at the
end only if that was sent. The end of a blink, and the marker recovery,
still put the logo back to dark as a closed lid has it.

What the closed lid did last is a line in the View menu, under Apple Logo
Blink Styles: "Lid Closed: blinking", "blinking (display told awake)",
"stayed dark, macOS keeps the logo off", or why it was not tried (on
battery, no keyboard to keep dark, no lid sensor), with the time. The
child writes it to logo-lid next to desktop.json, with the readings on a
second line; the app reads it every two seconds.

The battery and keyboard gates stay: the lid helper only holds on the
adapter, and a keyboard told the lid is open would light under it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:25:22 +01:00
Codeman maintainer 4c53d424a7 fix(desktop): a firmer proof under the closed lid, and a stderr note that cannot abort
The proof that the logo moves under the closed lid now needs a fade level
above 0.01, not just above 0, so no drift of a fade held at 0 passes for a
rise; the slowest rise (the breath's) is past it in about 80 ms. The note
on stderr is a writeln! whose error is dropped: eprintln! panics on a
failed write, and the blink child has panic = "abort".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 21:41:49 +01:00
Codeman maintainer 39f2e847d8 feat(desktop): the lid logo keeps blinking with the lid closed
With the lid closed, corebrightnessd tells every module so and keeps the
logo dark, so the blink stopped as soon as the lid came down, which is
exactly when the logo on the lid is the one thing in view.

While the Mac stays awake with the lid closed on its power adapter (Keep
This Mac Awake's lid helper, or an external display), the blink child now
does what LogoLight's Lit with Lid Closed measured on the real MacBook:
once the daemon has handled the lid (CBClamshellState), it tells the logo
the lid is open (ClamshellUpdate 0) and each keyboard alone that it is
closed, so the keyboard stays dark and is never relit after a Blank.

The first rise after that has to move the logo within 1.5 s. If it does
not (with no display on, the daemon may still hold the logo dark), the
lid is told closed again, tried once more, then left dark until the lid
opens, a display comes on or the agents' state changes. A logo the daemon
takes back (it resends the lid state when a display or keyboard comes up)
is told again. Ending a blink under the closed lid hands the logo back to
normal and then writes ClamshellUpdate 1, since normal there is dark; the
marker recovery does the same for a killed child. Never on battery (a
dark wake must not light the logo in a bag), never without the keyboard
IDs, and nothing is written when the lid opens. Every step is a line on
stderr so a real-Mac run shows which one stopped it.

Not seen on a real MacBook yet; the README says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 21:36:32 +01:00
Codeman maintainer 7a48778bf4 chore: the 1.50.1 changeset names #800 and its close rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:01:12 +01:00
Codeman maintainer 91a4aa6b4e Merge #800: fix(desktop): closing the window quits when keep-awake watches nothing here
# Conflicts:
#	packages/desktop/README.md
2026-11-10 18:01:05 +01:00
Codeman maintainer 3a3e2ee1a4 fix(desktop): closing the window quits when keep-awake watches nothing here
From the review of #800: the window hid the app whenever a keep-awake mode
was chosen, also with no server open (the connect page) or with a Codeman
on another machine, where keep-awake holds nothing. Closing the window then
left a hidden app in the Dock for nothing, and it never quit on close again
until Cmd+Q.

close_hides_app() now decides from the open server as well: it hides only
while keep-awake watches a Codeman on this Mac, the same server and the same
is_this_mac() rule the worker uses. It still hides while keep-awake is only
paused (on battery with Only When Plugged In, no agent at work, no answer),
since it carries on by itself once that changes.

The test now covers loopback, this Mac's own address, another machine's
address and no server, and fails with the old rule. The frame is recorded
and the config saved once, before the hide or quit choice, and the Reopen
comment says how a hidden app comes back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:57:06 +01:00
Codeman maintainer e06257c937 chore: one patch changeset for 1.50.1
Folds the carried changesets of #732 (minor), #791 and #705 into one
patch changeset for the whole release, so the bump is 1.50.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:19:51 +01:00
Codeman maintainer 0e13d9b12f fix(voice): edits from the #705 review
- docs/api-reference.md and the voice-stream.ts overview now say a
  transcript frame is the current utterance so far and a final frame
  ends it, as the relay's own comment and the invariants doc do.
- VoiceInput.cleanup() hides the Send button before stop(true), so the
  button stays up for the words stop(true) salvaged and inserted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:19:05 +01:00
Codeman maintainer 7f35f798ee Merge #705: fix(voice): preserve thinking pauses and Android dictation 2026-11-09 18:16:12 +01:00
Ludo FourrageandClaude Opus 5.5 308de1fd4d fix(voice): 30 s and Manual outlast silent Web Speech runs
_onWebSpeechEnd counted every run that ended without a result toward the
two-retry cap, however long the run lasted. Under 30 s or Manual, each
no-speech window of the engine is one silent run, so the third ended the
recording at about 18 s of silence, before the deadline, and Manual
stopped on its own. Both recognition.start() sites now stamp the run start;
an empty run counts toward the cap only when it ended within a second of
starting (a recognizer that cannot listen), and any other run resets it.
The 250 ms backoff is unchanged.

The vm harness in test/voice-silence-timeout.test.ts now passes the test's
Date into the context, whose own Date ignores fake timers.

Also from review: the VoiceInput stubs of the four settings-ui.js tests
carry SILENCE_TIMEOUT_SECONDS; Settings-Reference lists Stop after silence;
the touchscreen Linux fallback leaves out ChromeOS and says a touch Linux
laptop still matches; the dead _webSpeechStream teardown is gone; the two
voice tests use vitest expect instead of node:assert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 07:57:14 -08:00
Ludo Fourrage 2d7cd46565 fix(voice): preserve Android transcripts and Claude utterances 2026-11-09 07:27:02 -08:00
Ludo Fourrage 0cedeb2657 fix(voice): make dictation silence timeout configurable 2026-11-09 07:26:44 -08:00
Codeman maintainer 4f29b0187a Merge #732: feat(settings): sync case options and selection across devices 2026-11-09 08:01:43 +01:00
687c75b490 Merge #720: fix(log-viewer): stop reconnecting after a startup error
The floating log viewer reconnected every few seconds when its tail-file
stream ended with a startup error (path refused, file missing, too large,
stream limit, tail spawn failure). It now closes the EventSource on an
`error` message that arrives before `connected`; an established stream
keeps its reconnect.

Squashed from #720 (johoja12). Maintainer edits while landing, as the
review said: the onerror close is dropped (it stopped the browser's own
retry after a transport failure, leaving the window on "Connecting"),
the test's check for a button nothing renders is gone, and the stubbed
stream sets `retry: 200` so a reconnect, if one came, shows within the
shorter wait.

Co-authored-by: Codeman maintainer <kay@stutz.science>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 07:58:33 +01:00
Codeman maintainer 9518f4ecec Merge fix/desktop-keep-awake-window-close: closing the window no longer ends Keep This Mac Awake
# Conflicts:
#	packages/desktop/README.md
2026-11-09 07:57:49 +01:00
Codeman maintainer 8d7d0c5c7f Merge #795: feat(desktop): keep the Mac awake while agents work, lid closed included
# Conflicts:
#	packages/desktop/README.md
2026-11-09 07:57:41 +01:00
Codeman maintainer 430bf6217b fix(desktop): closing the window no longer ends Keep This Mac Awake
Keep This Mac Awake lives in the app and ends when the app quits, and
closing the main window quit the app. So with a mode on, the red button
(or Cmd+W) followed by closing the lid let the MacBook sleep: the lid
request file was cleared and the helper released disablesleep at its
next run.

While a mode is on, closing the main window now hides the app the way
Cmd+H does (hide_application), so full screen and pop-outs come back as
they were on a click on the Dock icon. Cmd+Q and Quit from the Dock
still end it, through LoopDestroyed as before. With keep-awake off,
closing the window quits as it always did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 07:57:40 +01:00
Codeman maintainer 0bc5f0da14 Merge #794: feat(desktop): the lid logo breathes, blinks and flashes on its own fades, behind one switch 2026-11-09 07:57:02 +01:00
Codeman maintainer 8dc81d0089 Merge #792: feat(files): delete a file from the Files panel (from #529) 2026-11-09 07:57:02 +01:00
Codeman maintainer 5e80c0a1ca Merge #791: feat(path-picker): pinch, pan and wheel zoom for the image preview (from #608) 2026-11-09 07:57:01 +01:00
Codeman maintainer 20cd2eccc9 Merge #790: fix(home): swap the home surface on a fold whatever width the page loaded at 2026-11-09 07:57:00 +01:00
Codeman maintainer 2f9843c94a feat(desktop): a simpler Keep This Mac Awake menu
The owner found the options confusing after the first test build. The
menu now reads Off / While Agents Work / Always, then on a MacBook
Only When Plugged In and With the Lid Closed…, then one status line.

- "Always While Codeman Is Open" is now "Always"; the status line says
  "Awake: Codeman is open".
- "Only on Power Adapter" is now "Only When Plugged In".
- "Remove the Lid Helper…" is gone: the lid item is the helper.
  Ticking it installs the helper behind the administrator prompt,
  unticking it removes it behind another. The setting changes only
  once the prompt's command ran, so a cancelled prompt leaves the item
  as it was.
- Shorter status lines ("Awake: agents working, lid closed too",
  "Paused: on battery", "Sleeps as usual: no agent working").

Config keys and values are unchanged, so a saved choice carries over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 07:44:26 +01:00
Devvyn 607c88afd9 fix(settings): a tab-return read never undoes a pick or save made while it was out
- session-ui.js: drop the answer when the case-sync chain moved during the read; settings-ui.js calls _applyServerCaseSettings with ?.
- i18n.js: zh-CN for the failed case-options save toast, case name passed through
- tests: regression for the stale read, zh-CN toast row
2026-11-09 14:01:09 +08:00
Devvyn bb088bcd3e fix(settings): case sync tests follow lastPickedCase, a failed pick is not undone, rollback is covered
- sync-case-state browser and stale-refresh tests use lastPickedCase (the stale test now fails without its guard)
- saveLastUsedCase runs on the case-sync chain, and the follow is skipped while this device's own pick was not stored
- _caseStateSyncOn tolerates a harness without loadAppSettingsFromStorage
- lastPickedCase added to the config-backup volatile-key test and docs; rename rollback tests cover caseSettings and lastPickedCase
- wiki row moved to System
2026-11-09 12:31:30 +08:00
Codeman maintainer 79a6c03363 feat(desktop): keep the Mac awake while agents work, lid closed included
Codeman > Keep This Mac Awake in the macOS app: While Agents Work (an
agent at work, plus 5 minutes after the last one stops) or Always While
Codeman Is Open. Off by default, and only for a Codeman on this Mac.

- Idle sleep needs no password: the app holds an NSProcessInfo activity
  with IdleSystemSleepDisabled, which the kernel drops with the process.
  While it only watches for work, an activity that still allows sleep
  keeps App Nap from slowing its look at the server.
- On a MacBook, Only on Power Adapter (on by default) pauses it on
  battery, and Also With the Lid Closed installs a small LaunchDaemon
  behind macOS's administrator prompt. It sets pmset disablesleep only
  while the app keeps a fresh request file with its live pid, only on
  the power adapter, and undoes only what it set itself. The app writes
  the script and plist as root from the copies built into it.
- A status line at the bottom of the submenu says what it does now.

This brings the macOS part of the keep-awake idea from PR #595 into the
app; the server-side setting and the Linux route are left out for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 04:51:39 +01:00
Devvyn 4808ee7085 test(ui): stub the sync switch where a case delete saves lastUsedCase 2026-11-09 11:31:13 +08:00
Devvyn ea27adf104 fix(settings): case sync follows only a picked case and keeps its popover and undo honest
- follow lastPickedCase (written by saveLastUsedCase while sync is on), not lastUsedCase, so a quick-start or an agent's spawned worker no longer moves other devices
- close the open case-options popover when the follow or an import changes what it shows; follow through selectQuickStartCase(save:false)
- failed save reuses _noteSettingsWriteResult and says the choice lasts until the next sync
- rename undo renames the shared entry back instead of restoring the whole map; lastPickedCase follows a rename
- caseSettings and lastPickedCase added to the redaction, rename and backup-volatile lists and the docs
2026-11-09 11:17:16 +08:00
Codeman maintainer 7d96b3dcf6 feat(desktop): the lid logo breathes, blinks and flashes on its own fades, behind one switch
The 1.50.0 blink switched LEBacklightMode on and off. Every style was a lit
logo with short dark blips that differed only in how many there were, and
Working (Heartbeat) looked busier than Waiting for you (Wink), so urgency
read backwards. Each change was also a write to a setting corebrightnessd
keeps in root's preferences, and new users had to pick a style in three
submenus before anything happened.

Now one rule: the more the logo moves, the more it needs you.

- View > Blink Apple Logo is one switch, off until turned on. It shows
  Breathe while agents work, Calm for a yellow tab and Alarm for a red one.
  Each state can take another style under View > Apple Logo Blink Styles.
  Picking a style turns the switch on, and turning it on with every state
  off brings back those three.
- Breathe is new: a slow fade out and back in, every 6.3 s.
- The logo moves on the LED's own hardware fades (LEPWMFactorFadeDuration,
  then DisplayLogicalState Blank or Unblank), as LogoLight measured them on
  the real logo (MIT, reimplemented in Rust, not copied). A blink writes no
  setting and leaves nothing behind a restart. LEBacklightMode is only read,
  so a logo the user switched off stays off.
- The direction of a fade is tracked, because the daemon ignores a write in
  the direction it already heads and the state can't be read back. When the
  direction is unknown, the logo is turned round with a 1e9 s fade first.
- After every Blank the keyboards alone are told Unblank, and only while
  the built-in display is awake (CGDisplayIsAsleep). They are told Blank when
  it sleeps and Unblank when it wakes. When it sleeps, the fade duration
  also goes back to automatic, as LogoLight does.
- A rise from exactly dark waits for a display frame, up to 0.5 s. Short
  dark beats (up to 0.35 s) turn round before their fall ends, so they never
  reach 0. Long ones time their light from when the rise starts.
- Every end puts the logo back: lit, with the automatic fade duration. A
  new marker file (logo-fade) covers a killed child. 1.50.0's marker
  (logo-blink) still switches back on a logo that version left off.
- While LogoLight runs, Codeman leaves the logo to it (process-name check),
  and the app says so when the switch is turned on.
- Settings from 1.50.0 keep their styles, with the switch on if any was
  chosen. All three off was that version's default, so it reads as
  untouched.

The tests run the blink against a fake corebrightnessd that moves in
17.5 ms frames on the measured spring curve. It starts each fade 19 ms
after the write, ignores a write in the direction it already heads, and
holds a rise from exactly dark back by a configurable delay. They pin these
cases:
- dips never reach 0
- long dark beats keep their light
- no keyboard is told to light while the display sleeps
- every end leaves the automatic duration and a lit logo
- a logo switched off or driven by LogoLight is left alone
- old settings are migrated

An ignored probe (what_this_mac_answers) reads a Mac's displays,
processes, keyboards and logo without writing anything.

Not measured on the real logo, since there was no MacBook to test on:
- how the 0.12 to 0.36 s fades of the hard styles look
- whether a rise from a dip (about 7%) really skips the frame wait
- the keyboard during Alarm
- the keyboards through a real display sleep

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:55:59 +01:00
Codeman maintainer 078bd74bd1 fix(files): the review's fixes for the Files panel delete
The fixes #529's review asked for, each with a test.

- No inline handler. The button quoted the path inside
  onclick="...decodeURIComponent('<path>')", and encodeURIComponent leaves
  ' alone, so a file named x'-alert(1)-'.txt ran script in Codeman's
  origin and it's.txt was a syntax error. It is now bound in the existing
  .file-tree-item loops from the row's escaped data-path.
- Symbolic links are refused, never followed. validateSessionFilePath()
  returns the realpath, so deleting AGENTS.md -> CLAUDE.md deleted
  CLAUDE.md and left the link dangling. The path as named is lstat'ed,
  a link answers 400, and the inode is compared again before the unlink.
- The edit route's confinement whole: validateLocalSessionFilePath()
  behind withGuardedOutsideWorktree(), so an absolute path in a followed
  outside worktree obeys the same rules, and the sensitive, blocked and
  .git checks run on both the realpath and the path as named.
- Adopted sessions are refused like remote ones (the /tmp exception of
  the older file surfaces is not copied to a new one); Docker workspaces
  are allowed, as on the edit route. The workspace is probed bounded
  first (#516). Errors are thrown with their status.
- Deleting from search results re-runs the search, the row buttons stay
  visible on touch screens, the confirm and toasts have zh-CN text, and
  no button is drawn for remote or adopted sessions.
- Docs: CLAUDE.md, architecture-invariants, api-reference and the
  Working With Files wiki page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:09:46 +01:00
shenlvkang-collab dfe1af2e5f feat(files): delete a file from the Files panel
A delete button on each file row of the Files panel (tree and search
results) and a DELETE /api/sessions/:id/files?path= route that removes one
regular file from the session workspace, refused for remote (SSH)
sessions and behind the same sensitive-path, attachment-guard and .git
checks as the edit route.

Salvaged from #529.
2026-11-09 03:09:46 +01:00
Codeman maintainer 572ff202bb fix(path-picker): finish the image preview zoom from #608
The review items offered on #608, applied on top of the contributor's
zoom:

- `object-fit: scale-down` instead of `contain`, so a small image keeps
  its own size instead of being stretched to fill the preview. The pan
  clamp now uses the size the image is actually drawn at (natural size
  fitted with scale-down) rather than the image box, which let a small
  or letterboxed image be panned entirely out of view.
- Wheel zoom is proportional to deltaY (d3-zoom's scale: about 15% per
  100px notch, lines and ctrl+wheel trackpad pinches scaled up), capped
  at sqrt(2) per event, and a wheel event with no vertical delta zooms
  nothing. A trackpad scroll no longer jumps to 600%.
- The zoom controls show only once the image has loaded; on an error
  they are hidden and the gesture listeners are dropped with the image.
  Both handlers now check the request sequence like the text branch, so
  a late load or error from a replaced preview no longer resets the
  current preview's zoom or paints its error over the current image.
- test/path-picker-image-zoom.test.ts drives the real listeners in
  jsdom (focal point under the cursor and the pinch centre, pan clamp,
  wheel scaling, error, close and stale events) in place of the
  source-string checks in test/path-picker-ui.test.ts. 7 of its 10
  tests fail against the contributor's commit alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:09:45 +01:00
shenlvkang-collab ba6bff388a feat(path-picker): pinch, pan and wheel zoom for the image preview
The path picker's image preview gets zoom controls (out, fit/percentage,
in), a two-finger pinch that keeps the pinch centre in place, one-finger
pan while zoomed (clamped to the image bounds), double-click to toggle
2x, and wheel zoom on desktop. closePreview() removes every listener and
drops the zoom state.

Salvaged from #608.
2026-11-09 03:09:45 +01:00
Devvyn f8ff431d30 fix(settings): case sync asks and merges only while it is on
- session-ui.js: no GET /api/settings on a tab return unless this device's cached syncCaseState is true
- system-routes.ts: caseSettings is merged only while syncCaseState is true (as stored or set by the same PUT)
- sync-case-state tests cover both; wiki notes the stored options stay when sync is turned off
2026-11-09 10:03:08 +08:00
Codeman maintainer 7602b6db21 fix(home): swap the home surface on a fold whatever width the page loaded at
Folding a phone on the home screen left the welcome screen up when the page
had loaded wide (#702): the phone overview's breakpoint listener was wired
from its first show, which a wide-loaded page never reaches. Both home
screens' breakpoint listeners are now installed once from init() and act
only while a home surface is up.

A rail render queued just before the fold could also unhide the desktop
rail behind the phone overview; the debounced refresh now asks again when
it fires.

Tests: test/home-breakpoint-listeners.test.ts (gate) and
test/home-breakpoint-swap.browser.test.ts (browser suite: wide-first and
narrow-first across 599/600 and 767/768, plus a height-only resize).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 02:29:51 +01:00
Devvyn SatieandClaude Sonnet 5.5 a50d0db283 fix(settings): harden case sync (privacy, concurrent saves, failures, enable/disable)
- Redact caseSettings for non-admins and never apply a shared map on a read-only page (CASE-SYNC-001)
- Send one case per save and merge it inside mutateSettingsFile, so devices cannot discard each other's cases; rapid saves keep their order (CASE-SYNC-002)
- Check HTTP status and the response body, keep the local choice, report an unsynced save and reuse the forbidden-write handling (CASE-SYNC-003)
- Let the server decide whether sync is on: refresh the cached switch, discard stale refreshes (CASE-SYNC-004)
- Carry shared options through case rename and drop them on delete or unlink
- Give the switch an accessible name; add server, client and two-browser tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 08:43:46 +08:00
Devvyn d62b11586b feat(settings): optional sync of case options and selected case across devices 2026-11-09 08:25:38 +08:00
67 changed files with 9349 additions and 680 deletions

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@
"name": "codeman",
"source": "./plugins/codeman",
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
"version": "1.50.0",
"version": "1.50.1",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+35
View File
@@ -1,5 +1,40 @@
# aicodeman
## 1.50.1
### Patch Changes
- e06257c: ### Thanks
- @opticon454 for syncing case options and the picked case across devices, over five review rounds (#732).
- @ludovicfourrage for dictation that survives a thinking pause and the Android dictation fixes, over three review rounds (#705).
- @johoja12 for the log viewer reconnect fix (#720).
- @shenlvkang-collab for the image zoom (#608) and the Files panel delete button (#529), both carried forward in #791 and #792, and for the foldable phone report (#702).
**A MacBook keeps working with the lid closed (#795).** The Codeman app for Mac has a new menu, Codeman > Keep This Mac Awake: Off, While Agents Work (and 5 minutes after the last one stops) or Always. It only acts for a Codeman running on this Mac, and pauses on battery unless you untick Only When Plugged In. On a MacBook, With the Lid Closed… keeps a closed Mac awake on its power adapter. It asks for your administrator password once and installs a small helper; unticking it removes the helper. Behaviour change: while it keeps watch for a Codeman on this Mac, closing the Codeman window hides the app instead of quitting it, so the Mac stays awake; click the Dock icon to bring it back, and quit with Cmd+Q (#800). With no server open, or one on another machine, closing the window still quits.
**The lid logo breathes while agents work (#794).** The Apple logo blink from 1.50.0 now has one switch, View > Blink Apple Logo. It breathes while agents work, blinks for a yellow tab and flashes for a red one: the more it moves, the more it needs you. It now moves on the logo's own hardware fades, so a blink no longer writes a system setting. Styles you picked in 1.50.0 are kept.
**The logo keeps blinking with the lid closed (#801).** While the Mac stays awake behind a closed lid on its power adapter (Keep This Mac Awake > With the Lid Closed…, or an external display), the logo goes on showing your agents, and the keyboard stays dark. macOS keeps the logo dark under a closed lid, so the app tells the logo the lid is open, the way LogoLight 1.3.1 does. A line under View > Apple Logo Blink Styles says what the closed lid did last: blinking, or why not. On battery nothing changes: the Mac sleeps when you close it.
**Codeman and LogoLight take turns on the logo (#802).** With LogoLight 1.3 or later running, LogoLight steps aside while Codeman shows its agents on the logo, and takes the logo back afterwards. Until now Codeman left the logo alone whenever LogoLight ran. An older LogoLight still keeps the logo, and Codeman tells you to update it when you turn the switch on.
**Dictation survives a thinking pause (#705).** A new Voice setting, Stop after silence, sets how long a pause may last before dictation stops: the provider's default, 3, 6, 15 or 30 seconds, or Manual. Claude dictation keeps going from one sentence to the next until you press Stop, and keeps your last words. On Chrome for Android, words are no longer repeated or lost at the end. Behaviour change: on iOS Safari dictation no longer stops after 750 ms without new words; it stops at the silence deadline like everywhere else.
**Sync cases across devices (#732).** A new setting, off by default, keeps each case's Agent Teams and Opus 1M options on the server instead of in each browser. When you come back to a tab, the case picker follows the case you last picked on another device.
**Delete a file from the Files panel (#792).** File rows in the Files panel, tree and search results, have a delete button that asks first. Symbolic links, `.git`, sensitive files and remote or adopted sessions are refused.
**Zoom the path picker's image preview (#791).** Pinch and drag on a phone, the mouse wheel or a trackpad on a desktop, double-click for 2x, or the zoom buttons. A small image now shows at its own size instead of stretched.
**Fixes.**
- **The phone overview appears on a fold (#790).** Folding a phone, or narrowing a window below 600px, left the welcome screen up when the page had loaded wide. Refs #702.
- **The log viewer stops reconnecting after a startup error (#720).** A refused path, a missing or too large file, the stream limit or a failed tail made the floating log viewer reconnect every few seconds.
- **The local model install view no longer flashes Paused at the end.** A view read while the install job finished could show the job as paused.
**Fixes applied while landing.** #720 keeps the browser's own retry after a network error before the stream connects. The Mac app's window-close change (#800) came from checking #795 against how people quit the app. #705: the green Send button now stays up for the words saved when dictation is cut off by a reconnect, and the API reference says what a voice transcript frame holds.
**Docs and tests.** Both READMEs have a Download for Mac button at the top. The Mac app's code was reorganised into smaller modules (#789) with no change in behaviour.
## 1.50.0
### Minor Changes
+6 -4
View File
@@ -52,7 +52,7 @@ Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@chan
When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`docs/releasing.md`](docs/releasing.md) and follow every step in order**; never improvise a release from memory. It covers the changeset, `npm run version-packages`, the `**Version**` line below, the branch check and explicit-path commit, deploy, the getcodeman.com badge, watching BOTH CI workflows, the MCP registry publish, and the Discussions announcement.
**Version**: 1.50.0 (must match `package.json`)
**Version**: 1.50.1 (must match `package.json`)
## Project Overview
@@ -186,7 +186,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. ⚠️ Every piece is load-bearing: exactly one server (a second click REUSES it), restarted when the browser authority changes (`--trusted-host`, last asker wins), killed on shutdown via `stopDeepSeekWeb()` (the detached child would otherwise outlive Codeman and hold its port), and failures returned to the caller. ⚠️ Never hardcode the port: search from 3080 across 40, detect free ports by BINDING, then wait for the server to really answer. ⚠️ Both `POST` and `DELETE` must stay behind `canUsernameRunPrivilegedCommands` (booting a profile runs its plugin code; the server is shared). → [architecture-invariants#deepseek-web-ui](docs/architecture-invariants.md#deepseek-web-ui)
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<claude config>/projects/<old>` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, so a failed or refused registry write puts `settings.json` back and leaves the registry as it was. ⚠️ Every write to `linked-cases.json` (link, unlink, rename, and a create or clone into a custom folder) goes through `mutateLinkedCases()` (`src/web/linked-cases-writer.ts`: serialized, temp file + rename, and a file that does not parse is refused, never read as `{}`; the one direct writer is config-backup restore, `restoreConfigBackup()`, which must work on such a file), and a check that must still hold when the entry is written (the name is free; for a rename, the old entry still names the folder) runs INSIDE its callback: two clones that claimed one name used to both pass a re-read made outside it and both answer success. Pinned by `test/linked-cases-writer.test.ts`, and for a rename by `test/routes/case-rename-routes.test.ts`.
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`, `lastPickedCase`, `caseSettings`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<claude config>/projects/<old>` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`, `lastPickedCase`, `caseSettings`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, so a failed or refused registry write puts `settings.json` back and leaves the registry as it was. ⚠️ Every write to `linked-cases.json` (link, unlink, rename, and a create or clone into a custom folder) goes through `mutateLinkedCases()` (`src/web/linked-cases-writer.ts`: serialized, temp file + rename, and a file that does not parse is refused, never read as `{}`; the one direct writer is config-backup restore, `restoreConfigBackup()`, which must work on such a file), and a check that must still hold when the entry is written (the name is free; for a rename, the old entry still names the folder) runs INSIDE its callback: two clones that claimed one name used to both pass a re-read made outside it and both answer success. Pinned by `test/linked-cases-writer.test.ts`, and for a rename by `test/routes/case-rename-routes.test.ts`.
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key); the one exception is `ownEnv`'s `homeEnvVar` entry, the session's OWN home path (absolute only: a `clis.json` override can name any variable there), which `setCustomModel()` puts back on clear and recovery clamps, since the selection overwrote the tmux `setenv` that held it. ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. ⚠️ Every write of `custom-model-hosts.json` goes through `mutateCustomModelHosts()` (`src/custom-model-hosts-writer.ts`: one at a time, temp file + rename, the route's check inside the callback, and a file that does not parse is refused with 409, never read as `[]` and replaced); the one direct writer is config-backup restore. The endpoint id `local-ollama` is reserved for the local model install (see Local model install). Pinned by `test/custom-model-hosts-writer.test.ts`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
@@ -216,7 +216,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Agent inbox** (discussion #558; persistence `agentInboxPersistEnabled`, SYNCED, default OFF): per-session mailbox in `web/agent-inbox.ts`, routes `routes/inbox-routes.ts`, CLI `codeman agent post|inbox|ack`. Never writes to a pane, never in `toState()` (counts via `GET /api/agent-inbox/summary`). ⚠️ Read until ack by id; seen/ack only for the receiver resolved via `resolveParentSessionId()` (ack from anyone else = 403); `?wait=` parks while nothing is UNSEEN; full = 409, never drop the oldest; dropped on delete (`killMux`) only, boot prune only after a successful restore. → [architecture-invariants#agent-inbox](docs/architecture-invariants.md#agent-inbox)
**Voice dictation via Claude** (`claudeVoiceEnabled`, SYNCED, default OFF): the mic transcribes through this machine's Claude Code login (the CLI `/voice` backend) instead of Deepgram; the browser captures, `src/web/voice-stream.ts` relays to Anthropic. ⚠️ The OAuth token never reaches the page. ⚠️ Credentials are READ-ONLY (`src/claude-credentials.ts`): never refresh them (it rotates the refresh token and can sign the user out of their CLI). ⚠️ Capture must be linear16/16 kHz/mono via an AudioWorklet; `voice-pcm-worklet.js` borrows voice-input.js's `?v=` token, so edit the two together. ⚠️ Claude transcript frames are cumulative: replace, never append. → [architecture-invariants#voice-dictation-via-claude](docs/architecture-invariants.md#voice-dictation-via-claude)
**Voice dictation via Claude** (`claudeVoiceEnabled`, SYNCED, default OFF): the mic transcribes through this machine's Claude Code login (the CLI `/voice` backend) instead of Deepgram; the browser captures, `src/web/voice-stream.ts` relays to Anthropic. ⚠️ The OAuth token never reaches the page. ⚠️ Credentials are READ-ONLY (`src/claude-credentials.ts`): never refresh them (it rotates the refresh token and can sign the user out of their CLI). ⚠️ Capture must be linear16/16 kHz/mono via an AudioWorklet; `voice-pcm-worklet.js` borrows voice-input.js's `?v=` token, so edit the two together. ⚠️ Claude transcript frames are cumulative within each utterance: replace interims, append endpoint finals once, and keep recording until Stop or the configured silence deadline. → [architecture-invariants#voice-dictation-via-claude](docs/architecture-invariants.md#voice-dictation-via-claude)
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
@@ -243,6 +243,8 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership, where an absolute path inside a followed outside worktree (`SessionState.outsideWorktree`) counts as a second workspace root) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
**Files panel delete** (salvaged from #529; `DELETE /api/sessions/:id/files?path=`, the × on a file row of the Files panel, tree and search results): removes ONE regular file and is the **only file surface that deletes**. It takes the edit route's confinement and guards whole (ownership via `findSessionOrFail`, `validateLocalSessionFilePath` behind `withGuardedOutsideWorktree`, so an absolute path in a followed outside worktree obeys the same rules, sensitive/blocked/`.git` 403), and both spellings are checked, the realpath and the path as named. ⚠️ **A symbolic link is refused (400), never followed**: the confinement check returns the REALPATH, i.e. the link's target, so an `unlink` of it deleted `CLAUDE.md` for `AGENTS.md -> CLAUDE.md` and left the link dangling. The final component is `lstat`ed on the path AS NAMED, only a plain file goes on, the inode is compared again before `unlink`, and `unlink` never follows a final link. A dangling link fails realpath and reads as 404. ⚠️ Remote sessions are refused before any path check (a same-named local folder would take the delete), and so are adopted ones (the `/tmp` file-surface exception is not copied to a new surface); Docker workspaces are allowed like the edit route. The workspace (and the outside worktree root) is probed bounded first (#516, `422` on a dead mount). ⚠️ The button is bound from the row's escaped `data-path` in the `.file-tree-item` loops, NEVER an inline `onclick` (`encodeURIComponent` leaves `'` alone, so `x'-alert(1)-'.txt` ran script); it asks with `confirm()`, reloads through `_reloadFileBrowserKeepingQuery()` so a search re-runs, stays visible under `@media (hover: none)`, and is not drawn for remote or adopted sessions. Tests: `test/routes/file-delete-routes.test.ts`, `test/file-delete-ui.test.ts`. → [architecture-invariants#files-panel-delete](docs/architecture-invariants.md#files-panel-delete)
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. Tree and search share one walk shape: a per-directory round-robin (breadth-first, so fair per directory, not per subtree) over one 5,000-node budget, where a folder's own files count as one share beside the subfolders it just found (`walkTurnEnd()`). ⚠️ That budget caps what is LISTED, never what is read: a read keeps only what the listing can use (`readWalkFolder()`) and `MAX_WALK_ENTRIES_READ` (250,000) stops opening folders, or one GET reads the whole workspace into memory. ⚠️ A folder below the walk's root is probed (#516) before its `readdir`, and anything but `directory` is skipped unflagged. ⚠️ A directory has its node before it is read, so every cursor still queued or unread when the walk ends must get `incomplete: true` or it reads as an empty folder. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
**Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` and `root=live-any` on `GET /api/sessions/:id/files`): Claude's EnterWorktree moves the process, and `paneCwd` follows it (tmux reads the process cwd, measured on 2.1.318, back out on ExitWorktree too), while `workingDir` stays the session's identity. `refreshLiveWorktree()` (server.ts) turns a changed `paneCwd` into `SessionState.liveWorktree` by reading the `.git` FILE (never spawning git), walking up and stopping at `workingDir`, a repository or submodule of its own, or anything unreadable. That is the lookup for a pane inside `workingDir`; a pane outside it goes to `findOutsideWorktree()`, described below. ⚠️ The security argument has two halves. For a worktree inside `workingDir` the lookup only answers a folder strictly below it, but that is a LEXICAL judgement made when the pane moves, and a symlink can replace the folder or a parent later without moving the pane, so `root=live` re-checks on every request (`canWalkLiveWorktree()`, file-routes.ts: walk the worktree only when it resolves strictly inside the resolved `workingDir`, else walk `workingDir`). The walk starts there, returned `path`s stay relative to `workingDir`, and the routes keep confining to `workingDir`, so never make an INSIDE `liveWorktree` a confinement root. ⚠️ Only that check uses resolved paths: the walk keeps the session's spelling, or a workspace reached through a symlink stops producing paths relative to `workingDir`. ⚠️ A plain subfolder is deliberately no answer, or a `cd` in a shell would re-root the panel. ⚠️ A listing reports `worktree` whichever folder it walked, even when the worktree has gone or the check refused it: `_followFileBrowserWorktree()` compares that with the session's state, and an absent report would reload forever. The one exception is a listing without `root=live-any`, which omits an outside worktree; while the session folder is pinned, the panel compares with the worktree it pinned away from (`pinnedWorktree`, panels-ui.js). ⚠️ **A worktree OUTSIDE `workingDir` (`git worktree add ../feature`, or an EnterWorktree placed beside the repo) IS a second root**, answered only through `findOutsideWorktree()` with `outside: true` and published on the wire as `SessionState.outsideWorktree`, never in `liveWorktree`, which stays inside-only: the session's own repository must vouch for it by FILE READS (`workingDir` must be the top of its repository or worktree, so a session in a subfolder follows nothing outside it; the candidate's `.git` FILE names `<common dir>/worktrees/<id>` and that entry's `gitdir` names the candidate back, or its `.git` FOLDER is the session's common dir itself, which is the main worktree for a session started in a linked one, and a symlinked `.git` is refused; never spawn git from the pane watcher). It is refused at `/`, home or above, around `workingDir` and at/inside/around a Docker case workspace (an unreadable Docker list follows nothing). It is refused while attachment workspace confinement is on. It is also refused at, inside or around an attachment blocked tree, and at a root that is itself a sensitive folder such as `~/.ssh` (a child probe, since the sensitive patterns describe file locations). These rules are judged when the pane moves like the Docker rule, and multi-user mode applies `folderAllowedForUser`. The routes also re-check confinement and the blocked trees on every request (`isOutsideWorktreeRootRefused()`), so turning confinement on or blocking the folder stops serving it at once. ⚠️ `validateLocalSessionFilePath()` also refuses any file there that `isSensitivePath()` blocklists, so a root such as a credential folder can never serve its keys. Walking it is opt-in: only `root=live-any` (which the panel sends) lists it, with ABSOLUTE `path`s, while `root=live` never walks an outside worktree: it walks `workingDir` with relative paths, and its listing omits `worktree` for an outside one. An absolute path inside it is confined to it, falling back to `workingDir` when the worktree root refuses it (`validateLocalSessionFilePath()`, route-helpers.ts), while a relative path still resolves against `workingDir` alone. ⚠️ It is recorded as a REAL path, and every request re-checks that the folder still resolves to exactly that path (`outsideWorktreeRootFor()`, `canWalkOutsideWorktree()`), or a symlink swapped in would re-root the routes anywhere. Tests: `test/live-worktree.test.ts`, `test/live-worktree-outside.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/routes/file-routes-outside-worktree.test.ts`, `test/file-browser-worktree.test.ts`, `test/file-browser-outside-worktree.test.ts`. → [architecture-invariants#files-panel-in-a-worktree](docs/architecture-invariants.md#files-panel-in-a-worktree)
@@ -265,7 +267,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside sessions, **NOT a `SessionMode`**, and is proxied through Codeman's own origin (`/webview/<cap>/...`). ⚠️ The proxy is NOT an API surface: its capability-based auth exemption stays fenced to safe methods and non-route paths (`test/webview-auth-exemption.test.ts`). ⚠️ Iframes omit `allow-same-origin` unless `trusted`, and `Authorization`/`codeman_session` are stripped upstream in both modes. ⚠️ **Egress guard**: link-local and cloud-metadata targets are refused at save time, by a sync hostname check at each connect (IP literals skip DNS), AND on the resolved address (`webview-egress.ts`); use the `undici` package's own `fetch` + `Agent`, never Node's global fetch. ⚠️ Loopback links in agent output auto-open as proxied web tabs (`openLinkThroughWebTabIfLoopback`), but never auto-route `*.localhost` (prompt-injectable DNS). Capabilities are revoked on logout (`revokeOwner`). → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. ⚠️ `settings.json` is ONE server-wide file, so every write to it is admin-only (`PUT /api/settings`, `/api/execution/model-config`, `/api/cases/order`, plus `PUT /api/config`: 403 `FORBIDDEN` before parsing), a non-admin's quick-start/Ralph start skips `lastUsedCase`, its GET hides `voiceSettings.apiKey`/`caseOrder`/`lastUsedCase` from a non-admin (`settingsForNonAdmin`), and the browser skips those writes for a non-admin via `_settingsReadOnly()` (settings-ui.js; also true once a write came back 403 `FORBIDDEN`, since `/api/me` can be slow or fail; their run mode and respawn presets stay per-device, `_applyOwnSettings`) (`test/routes/system-routes-settings-admin-only.test.ts`). System ops are admin-only too (`/api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate`, `/api/cleanup-state`), and a non-admin's `/api/auth/revoke` revokes only their own logins (`test/routes/system-routes-system-ops-admin-only.test.ts`). → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md`
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. ⚠️ `settings.json` is ONE server-wide file, so every write to it is admin-only (`PUT /api/settings`, `/api/execution/model-config`, `/api/cases/order`, plus `PUT /api/config`: 403 `FORBIDDEN` before parsing), a non-admin's quick-start/Ralph start skips `lastUsedCase`, its GET hides `voiceSettings.apiKey`/`caseOrder`/`lastUsedCase`/`lastPickedCase`/`caseSettings` from a non-admin (`settingsForNonAdmin`), and the browser skips those writes for a non-admin via `_settingsReadOnly()` (settings-ui.js; also true once a write came back 403 `FORBIDDEN`, since `/api/me` can be slow or fail; their run mode and respawn presets stay per-device, `_applyOwnSettings`) (`test/routes/system-routes-settings-admin-only.test.ts`). System ops are admin-only too (`/api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate`, `/api/cleanup-state`), and a non-admin's `/api/auth/revoke` revokes only their own logins (`test/routes/system-routes-system-ops-admin-only.test.ts`). → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md`
**Away digest**: `GET /api/away-digest` aggregates what happened while you were away from the lifecycle log, run-summary events, live sessions, token stats, and recent subagents. Pure aggregator in `web/away-digest.ts`. ⚠️ Returns `{success:true,digest}`, a legacy raw-ish shape consistent with the other raw GET handlers in `system-routes.ts`; frontend and tests read `.digest`. → [architecture-invariants#away-digest](docs/architecture-invariants.md#away-digest)
+1 -1
View File
@@ -108,7 +108,7 @@ The first run asks three questions (data folder, port, password; Enter takes the
**[Download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · one universal app for Apple Silicon and Intel · macOS 11.3 or later
A small native app that opens Codeman in its own window. On a Mac without Codeman it installs it for you, with no Terminal and no administrator password, shows the install as a checklist, and offers to install a coding agent if the Mac has none yet. On a Mac that already runs Codeman it finds the server and opens it, or starts it when it is stopped. Under **Another machine** it connects to Codeman on your Mac mini, a home server or your tailnet, with the password kept in the Keychain, and can start an install over there through SSH. On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, **View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of your own for working, for waiting for you and for needing permission; all three stay off until you choose. The app is Rust around macOS's own WebKit, not a bundled browser, so the download is under 5 MB.
A small native app that opens Codeman in its own window. On a Mac without Codeman it installs it for you, with no Terminal and no administrator password, shows the install as a checklist, and offers to install a coding agent if the Mac has none yet. On a Mac that already runs Codeman it finds the server and opens it, or starts it when it is stopped. Under **Another machine** it connects to Codeman on your Mac mini, a home server or your tailnet, with the password kept in the Keychain, and can start an install over there through SSH. On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, **View > Blink Apple Logo** makes the logo show what your agents are doing: it breathes while they work, blinks when one waits for you and flashes when one needs permission, and each state can take a style of your own. It stays off until you turn it on. The app is Rust around macOS's own WebKit, not a bundled browser, so the download is under 5 MB.
Open `Codeman.dmg`, drag Codeman into Applications and open it from there. macOS asks once whether to open an app downloaded from the internet: click **Open**.
+1 -1
View File
@@ -110,7 +110,7 @@ bash docker/Start-Codeman.sh
**[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · 同一个通用应用,适用于 Apple 芯片和 Intel · macOS 11.3 或更高版本
一个小巧的原生应用,在它自己的窗口里打开 Codeman。在还没有 Codeman 的 Mac 上,它会替你安装,不用终端,也不需要管理员密码,用一张清单显示安装进度;如果这台 Mac 上还没有编程智能体,它还会提出帮你装一个。在已经运行 Codeman 的 Mac 上,它会找到服务器并打开;服务器停着时则替你启动。在 **Another machine** 下,它可以连接你的 Mac mini、家用服务器或 tailnet 上的 Codeman,密码保存在钥匙串里,还能通过 SSH 在那台机器上开始安装。在配备 PROLED XDR 显示屏、机盖 Apple 标志会发光的 MacBook Pro 上,**View > Blink Apple Logo** 会让标志按智能体在做的事闪烁:工作中、等你回复、需要权限各用一种你自己选的样式;在你选定之前,三种都保持关闭。这个应用用 Rust 写成,套在 macOS 自带的 WebKit 外面,不捆绑浏览器,所以下载不到 5 MB。
一个小巧的原生应用,在它自己的窗口里打开 Codeman。在还没有 Codeman 的 Mac 上,它会替你安装,不用终端,也不需要管理员密码,用一张清单显示安装进度;如果这台 Mac 上还没有编程智能体,它还会提出帮你装一个。在已经运行 Codeman 的 Mac 上,它会找到服务器并打开;服务器停着时则替你启动。在 **Another machine** 下,它可以连接你的 Mac mini、家用服务器或 tailnet 上的 Codeman,密码保存在钥匙串里,还能通过 SSH 在那台机器上开始安装。在配备 PROLED XDR 显示屏、机盖 Apple 标志会发光的 MacBook Pro 上,**View > Blink Apple Logo** 会让标志显示智能体在做的事:工作时缓慢呼吸,有智能体等你回复时闪烁,需要权限时快闪三下;每种状态也可以换成你自己选的样式。在你打开之前,它保持关闭。这个应用用 Rust 写成,套在 macOS 自带的 WebKit 外面,不捆绑浏览器,所以下载不到 5 MB。
打开 `Codeman.dmg`,把 Codeman 拖进「应用程序」文件夹,再从那里打开。macOS 会问一次是否打开这个从互联网下载的应用:点**打开**。
+2 -2
View File
@@ -3,7 +3,7 @@
"name": "io.github.Ark0N/codeman",
"title": "Codeman",
"description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman",
"version": "1.50.0",
"version": "1.50.1",
"websiteUrl": "https://getcodeman.com",
"repository": {
"url": "https://github.com/Ark0N/Codeman",
@@ -13,7 +13,7 @@
{
"registryType": "npm",
"identifier": "aicodeman",
"version": "1.50.0",
"version": "1.50.1",
"transport": {
"type": "stdio"
},
+4
View File
@@ -21,6 +21,7 @@
export const BROWSER_TEST_GLOBS = [
'test/wrapped-file-paths.browser.test.ts',
'test/command-palette-agent-arrows.browser.test.ts',
'test/voice-input.browser.test.ts',
'test/tmux-capture-sgr.browser.test.ts',
'test/tab-rail-resize.browser.test.ts',
'test/tab-activation.browser.test.ts',
@@ -51,6 +52,7 @@ export const BROWSER_TEST_GLOBS = [
'test/doctor-settings.browser.test.ts',
'test/git-status.browser.test.ts',
'test/edit-history.browser.test.ts',
'test/sync-case-state.browser.test.ts',
'test/split-pane-orchestration.browser.test.ts',
'test/split-pane-auto-collapse.browser.test.ts',
'test/spreadsheet-preview.browser.test.ts',
@@ -62,6 +64,8 @@ export const BROWSER_TEST_GLOBS = [
'test/phone-press-feedback.browser.test.ts',
'test/desktop-connect-page.browser.test.ts', // no server: the page is served by request interception
'test/local-model-ui.browser.test.ts',
'test/home-breakpoint-swap.browser.test.ts',
'test/log-viewer-reconnect.browser.test.ts',
];
/**
+27 -7
View File
@@ -901,6 +901,25 @@ already serves non-media text inert (`text/plain`, `nosniff`), so this just
keeps the server from storing what it would serve or an agent could run, and
html and svg are the ones deliberately kept out.
## Deleting a file (`DELETE /api/v1/sessions/:id/files?path=`)
The Files panel's delete button. `path` is what the listing gave the row: relative to the
session's `workingDir`, or absolute inside the session's outside worktree (see
[Live worktree](#live-worktree-liveworktree-outsideworktree)). It removes that one regular file
and answers `{ path }` as sent. It never deletes a folder and never follows a symbolic link:
the file routes resolve a link to its target, so deleting through one would remove the file it
points to.
| Status | When |
| ------ | ---- |
| `200` | The file is gone. |
| `400` | A remote (SSH) session or an adopted session (both refused before any path is looked at), a missing or repeated `path`, a symbolic link, or anything that is not a regular file. |
| `403` | A sensitive file (`.env`, credentials and the like), a blocked attachment tree, or anything under `.git`. |
| `404` | No such file, a path outside the workspace (`../`, an absolute path elsewhere, a link leading out), a dangling link, or a session the caller does not own in multi-user mode. |
| `409` | The file was replaced between the checks and the delete; reload the listing. |
| `422` | The workspace's mount did not answer. |
## New folder in the path picker
## New folder in the path picker
`POST /api/filesystem/mkdir` with `{ path, name, sessionId?, showHidden? }` makes one
@@ -1021,8 +1040,8 @@ of `liveWorktree` and `outsideWorktree`:
Like `liveWorktree`, it is never restored after a restart.
Such a worktree is a second root for the session's file routes. `file-content` (read and edit),
`file-raw`, `file-preview`, `file-thumbnail`, `GET /api/v1/download` and
`POST /api/v1/sessions/:id/html-view` accept an ABSOLUTE path inside it, while a relative path
`file-raw`, `file-preview`, `file-thumbnail`, `GET /api/v1/download`,
`POST /api/v1/sessions/:id/html-view` and `DELETE /api/v1/sessions/:id/files` accept an ABSOLUTE path inside it, while a relative path
still resolves against `workingDir` only. A file there that the sensitive-path blocklist names
(`.env`, `.aws/credentials`, `users.json` and the like) is refused on every one of those routes.
Codeman reports the worktree only when the session's own repository lists it, and only when `workingDir` is the top of that repository or of a linked worktree, so a session started in a subfolder of its repository reports none. It never reports:
@@ -1595,12 +1614,12 @@ A local case whose folder is the top of a git repository can keep **worktrees**
## Settings
`~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder` and `lastUsedCase`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder` and `lastUsedCase`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected. Every write to the file (these routes, and the `lastUsedCase` that `POST /api/quick-start` and `POST /api/ralph-loop/start` record before they answer) runs one at a time and replaces the file atomically (mode `0600`); the one write outside that queue is a config-backup restore, which must work on a file that does not parse. A write to a `settings.json` that exists but is not a JSON object is refused rather than starting from `{}`, which would erase every other setting: the routes below answer `409 CONFLICT` with a message naming the fix and leave the file untouched, the two starts skip their `lastUsedCase`, and an admin's `POST /api/cases/:name/rename` answers the same `409 CONFLICT`, checked before it ends any session or moves the folder. A missing or empty file still starts from `{}`.
`~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected. Every write to the file (these routes, and the `lastUsedCase` that `POST /api/quick-start` and `POST /api/ralph-loop/start` record before they answer) runs one at a time and replaces the file atomically (mode `0600`); the one write outside that queue is a config-backup restore, which must work on a file that does not parse. A write to a `settings.json` that exists but is not a JSON object is refused rather than starting from `{}`, which would erase every other setting: the routes below answer `409 CONFLICT` with a message naming the fix and leave the file untouched, the two starts skip their `lastUsedCase`, and an admin's `POST /api/cases/:name/rename` answers the same `409 CONFLICT`, checked before it ends any session or moves the folder. A missing or empty file still starts from `{}`.
| Method | Path | Body | Notes |
| ------ | ----------------------------- | ------------------------------------ | ----- |
| `GET` | `/api/settings` | none | The stored settings object. Never writes. For a non-admin in multi-user mode, without `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase`. |
| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable with no login (no `CODEMAN_PASSWORD`, not multi-user) is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`) unless the server runs with `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `--allow-unauthenticated-tunnel`. `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` does not count; when it is set, the refusal says so. An enable without that authorization is accepted only while the tunnel is already running and never stores `tunnelEnabled: true`; if the tunnel stops before the request would start it, it is not restarted and the answer is the same `403 OPERATION_FAILED` (the request's other settings are saved). |
| `GET` | `/api/settings` | none | The stored settings object. Never writes. For a non-admin in multi-user mode, without `voiceSettings.apiKey`, `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings`. |
| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `caseSettings` (per-case run options, `{ [case]: { agentTeams, opusContext1m } }`) is merged per case inside the serialised writer, so devices saving different cases never overwrite each other; it is ignored while `syncCaseState` is not `true` and answers `400` past 500 cases. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable with no login (no `CODEMAN_PASSWORD`, not multi-user) is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`) unless the server runs with `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `--allow-unauthenticated-tunnel`. `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` does not count; when it is set, the refusal says so. An enable without that authorization is accepted only while the tunnel is already running and never stores `tunnelEnabled: true`; if the tunnel stops before the request would start it, it is not restarted and the answer is the same `403 OPERATION_FAILED` (the request's other settings are saved). |
| `GET` | `/api/tunnel/status` | none | `{ running, url, loginConfigured }`. `loginConfigured` is `true` when a login protects the server (`CODEMAN_PASSWORD` set or multi-user mode on), the same test the tunnel guard above applies; with `false` the browser asks before every tunnel enable. Neither opt-in variable makes it `true`. |
| `GET` | `/api/execution/model-config` | none | `modelConfig` (cron's and the plan orchestrator's default model). |
| `PUT` | `/api/execution/model-config` | `{ defaultModel?, agentTypeOverrides?, ... }` | Replaces `modelConfig`. `403 FORBIDDEN` for a non-admin in multi-user mode. |
@@ -1609,7 +1628,7 @@ A local case whose folder is the top of a git repository can keep **worktrees**
A non-admin's `POST /api/quick-start` and `POST /api/ralph-loop/start` work as before but do not record their case as `lastUsedCase` (a server-wide key).
`POST /api/cases/:name/rename` (body `{ newName, renameFolder?, stopSessions? }`, strict) renames a case. It is not one of the admin-only writes above: in multi-user mode a non-admin renames their own cases, and only the `settings.json` part is skipped for them. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (they are closed as by `DELETE /api/sessions/:id`, nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it); a session that is still being created in the folder (constructed, not yet registered, so it can be neither listed nor stopped) makes a folder move `409` with `renameCode: "BUSY"` too, and a retry once it is registered lists it like any open session; the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards `settings.json` and the folder are put back (when the folder cannot be moved back, the answer is `500` and says it is now at the new path). A failed write is `500`. A linked case's entry is checked again where it is written, after any folder move, and a refusal there puts everything back the same way: a new name linked since the first check is `409 ALREADY_EXISTS` with `renameCode: "EXISTS"`, an old entry unlinked or changed since then is `409 CONFLICT` with `renameCode: "BUSY"`, and a `linked-cases.json` that stopped being readable or a JSON object is `409 CONFLICT` with no `renameCode` (one already damaged when the rename starts reads as empty, so a linked name answers `404`). An error from the rename itself carries `stoppedSessions`: how many sessions it ended before it failed. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder` and `lastUsedCase` (in multi-user mode, by an admin's rename only), `linked-cases.json` and Claude Code's `projects/<path>` history are updated (history already at the new path is merged into, never overwritten), and `case:renamed` is broadcast. A linked case is admin only in multi-user mode: for a non-admin the name is looked up in their own case space only (`404` when they have no case of that name).
`POST /api/cases/:name/rename` (body `{ newName, renameFolder?, stopSessions? }`, strict) renames a case. It is not one of the admin-only writes above: in multi-user mode a non-admin renames their own cases, and only the `settings.json` part is skipped for them. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (they are closed as by `DELETE /api/sessions/:id`, nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it); a session that is still being created in the folder (constructed, not yet registered, so it can be neither listed nor stopped) makes a folder move `409` with `renameCode: "BUSY"` too, and a retry once it is registered lists it like any open session; the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards `settings.json` and the folder are put back (when the folder cannot be moved back, the answer is `500` and says it is now at the new path). A failed write is `500`. A linked case's entry is checked again where it is written, after any folder move, and a refusal there puts everything back the same way: a new name linked since the first check is `409 ALREADY_EXISTS` with `renameCode: "EXISTS"`, an old entry unlinked or changed since then is `409 CONFLICT` with `renameCode: "BUSY"`, and a `linked-cases.json` that stopped being readable or a JSON object is `409 CONFLICT` with no `renameCode` (one already damaged when the rename starts reads as empty, so a linked name answers `404`). An error from the rename itself carries `stoppedSessions`: how many sessions it ended before it failed. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings` (in multi-user mode, by an admin's rename only), `linked-cases.json` and Claude Code's `projects/<path>` history are updated (history already at the new path is merged into, never overwritten), and `case:renamed` is broadcast. A linked case is admin only in multi-user mode: for a non-admin the name is looked up in their own case space only (`404` when they have no case of that name).
### System operations
@@ -1823,7 +1842,8 @@ same speech-to-text service the CLI's own `/voice` mode uses. Gated on the synce
little-endian PCM, 16 kHz mono (<= 64 KB per frame), plus JSON control frames
`{"t":"finalize"}` (ask for the final transcript) and `{"t":"stop"}`. Server
sends `{"t":"ready"}`, `{"t":"transcript","text","final"}` (each frame is the
WHOLE running transcript, not a delta), `{"t":"error","message"}` and
current utterance so far, not a delta, and a `final: true` frame ends that
utterance), `{"t":"error","message"}` and
`{"t":"closed"}`. Close codes: `4003` disallowed Host/Origin, `4004`
unavailable (reason in the close reason), `4008` too many concurrent streams.
Streams are capped in count and length (`src/config/voice.ts`).
+15 -3
View File
@@ -468,7 +468,7 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in
### File Viewer edit mode
**File Viewer edit mode** (issue #212, design in `docs/file-viewer-edit-plan.md`): the file-preview overlay can edit workspace text files in place — `GET /api/sessions/:id/file-content?edit=1` (read-for-edit) + `PUT /api/sessions/:id/file-content` (save), policy in `src/config/file-editing.ts`, UI in `panels-ui.js`. This is the **only file surface that writes**, so it carries every rule the read surfaces have plus its own:
**File Viewer edit mode** (issue #212, design in `docs/file-viewer-edit-plan.md`): the file-preview overlay can edit workspace text files in place — `GET /api/sessions/:id/file-content?edit=1` (read-for-edit) + `PUT /api/sessions/:id/file-content` (save), policy in `src/config/file-editing.ts`, UI in `panels-ui.js`. This is the **only file surface that writes file contents** (the Files panel's delete, [below](#files-panel-delete), removes whole files and borrows these confinement rules), so it carries every rule the read surfaces have plus its own:
- **Confinement is the read path's, plus write-only gates.** `findSessionOrFail` (ownership) → `validateSessionFilePath` (realpath + workspace boundary; escapes report as 404, same as reads) → sensitive-path + attachment-guard blocklists (403) → `.git/` subtree deny (403 — `.git/hooks/*` is code execution) → extension **allowlist** (400; `svg` and `env` deliberately excluded). ⚠️ **There is no `O_CREAT` anywhere in the handler** — that absence is what makes "edit-in-place only, never create" a structural property instead of a convention. Do not add a create path without treating it as a new security surface.
- **A truncated buffer must never become an edit buffer.** The plain preview truncates to `lines` (default 500); saving such a buffer would silently delete everything past the cut, and the hash check cannot catch it (the loaded prefix hashes differently from the full file, which reads as an ordinary conflict at best). `edit=1` therefore never truncates — it 413s over `MAX_EDITABLE_BYTES` (512KB) instead — and the frontend always re-fetches with `edit=1` before swapping in the textarea, even though the preview already holds content.
@@ -479,6 +479,18 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in
Tests: `test/file-editing-policy.test.ts` (pure policy), `test/routes/file-write-routes.test.ts` (deliberately **unmocked fs** against a real temp workspace — symlink/TOCTOU/mode behavior must be exercised for real).
### Files panel delete
**Files panel delete** (salvaged from contributor PR #529, which was closed; its upload half was dropped because `.codeman-uploads/` and the Attach button already cover it): a × on each file row of the Files panel, tree and search results alike, backed by `DELETE /api/sessions/:id/files?path=` in `file-routes.ts`. It removes ONE regular file and is the only file surface that deletes, so it takes the edit route's confinement whole and adds a rule of its own.
- **Order of checks**: `findSessionOrFail` (ownership; a foreign session is a 404 in multi-user mode) → remote refused (400, before any path check, for the same reason as the edit route: `validateSessionFilePath` resolves against the LOCAL filesystem, so a same-named local folder would take the delete) → adopted refused (400) → a missing or repeated `path` (400) → a bounded probe of `workingDir`, and of the outside worktree root for an absolute path (#516; `422` on a mount that does not answer) → `validateLocalSessionFilePath(await withGuardedOutsideWorktree(...))` (realpath + boundary, an absolute path inside a followed outside worktree confined to it, the attachment guard re-read per request; any escape, a dangling link and a missing file are all 404) → sensitive / blocked trees / `.git` (403), each tested against BOTH the realpath and the path as named, so a `.git` reached through a linked folder and a linked `.git` are both caught → the link rule below → not a regular file (a folder: 400).
- **A symbolic link is refused, never followed.** The confinement helper returns the REALPATH, which for a link is its target. #529 unlinked that path, so `DELETE ?path=AGENTS.md` with `AGENTS.md -> CLAUDE.md` deleted `CLAUDE.md` and left `AGENTS.md` dangling. The route therefore `lstat`s the path as the client named it (`resolve(workingDir, path)`, which keeps an absolute outside-worktree path as it is) and answers 400 for a link. The listing shows a link to a folder as a file row, so it gets a button, and the same 400 answers it. Deleting the link itself was the other option (confine the PARENT's realpath, unlink the link); it was not taken because refusing has no way to touch the target at all.
- **The file checked is the file deleted.** A path whose last part is not a link has a realpath naming the same inode, so the route `lstat`s the realpath again and refuses with 409 if the inode, the device or the file type changed in between. `unlink` never follows a final symlink, so a link swapped in after that still removes only the link. A parent folder swapped for a link between the realpath and the `unlink` is the same check-then-use window every file route accepts (an agent running as the same user can delete the file anyway).
- **Adopted and Docker.** An adopted session's folder is the neutral `/tmp`. The older file surfaces keep serving it as a standing exception (root `CLAUDE.md`, Adopted tmux sessions), which is not copied to a new surface, least of all one that deletes; the prompt-upload route refuses adopted sessions the same way. A Docker case workspace is bind-mounted at the same path and an `unlink` runs no hooks or filters (the reason git is kept away from those folders), so the route treats it like the edit route does: allowed.
- **Frontend.** The button carries no inline handler: `encodeURIComponent` leaves `'` alone, so #529's `onclick="...decodeURIComponent('<path>')"` let a file named `x'-alert(1)-'.txt` run script in Codeman's origin, and `it's.txt` was a syntax error. It is bound in the existing `.file-tree-item` loops (`renderFileBrowserTree`, `_renderFileBrowserSearchResults`) from the row's escaped `data-path`, with `stopPropagation` so the row does not also open the preview. `deleteFileFromBrowser()` asks with `confirm()` (the panel's other destructive actions use the same native dialog, translated by i18n.js's wrapper) and reloads through `_reloadFileBrowserKeepingQuery()`, which re-runs a search in the box, so a row deleted from search results leaves them. The row buttons stay visible under `@media (hover: none)`, where the old hover-only rule meant the first tap opened the preview instead. `_fileBrowserCanDelete()` draws no button for a remote or an adopted session (`session.remote` is undefined on an adopted one, so `session.adopt` is tested too).
Tests: `test/routes/file-delete-routes.test.ts` (unmocked fs again: the symlink defect only shows against a real link), `test/file-delete-ui.test.ts` (jsdom with `runScripts: 'dangerously'`, so the old inline handler would really run there, and the real `escapeHtml` from constants.js).
### File Viewer text view: rendered markdown and text toggles
**Rendered markdown + Lines/Wrap** (`_renderFilePreviewText()` and its helpers in `panels-ui.js`, buttons in the `.file-preview-actions` row): a `.md`/`.markdown` opened in the File Viewer renders as a document by default, with an `MD` pill back to source; the plain-text view has `Lines` (a CSS-counter gutter) and `Wrap` toggles. Codeman already had `marked` + DOMPurify behind `_renderMarkdown()` for the Response Viewer, so the viewer reuses that and the codebase keeps ONE markdown pipeline.
@@ -782,7 +794,7 @@ Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`
⚠️ Capture MUST be linear16/16 kHz/mono, so it uses an **AudioWorklet**, not MediaRecorder (which cannot emit raw PCM); `voice-pcm-worklet.js` is fetched from JS, so it is invisible to `cacheBustAssets` and borrows voice-input.js's `?v=` token — **edit the two together**.
⚠️ Transcript frames carry the WHOLE running transcript, not deltas: the Claude path replaces where the Deepgram path appends. Provider choice is `voiceSettings.provider` (`auto` prefers Claude → Deepgram → Web Speech). → `docs/claude-voice-plan.md`
⚠️ Claude transcript frames are cumulative within the current utterance: replace interims, then append each endpoint final once. An endpoint does not stop dictation unless finalization was requested. Finalization has a 5-second client backstop; reconnect cleanup salvages accumulated/interim text and releases the microphone immediately. Provider choice is `voiceSettings.provider` (`auto` prefers Claude → Deepgram → Web Speech). → `docs/claude-voice-plan.md`
### Files panel search
@@ -872,7 +884,7 @@ Paths, agent descriptions and diff lines render via `textContent` only (browser
⚠️ **The tracked list is a fixed allowlist** (`CONFIG_BACKUP_FILES`), never "everything in the data dir", and restore writes only those names whatever a manifest lists. `state.json` is deliberately NOT tracked: it is rewritten on every session event, so it would make a snapshot per event and bury the real changes. It also holds cron jobs and respawn presets, which is the documented gap.
⚠️ **One snapshot per real change.** The change check compares a digest of the file set with the newest manifest's `hash`, looking through `VOLATILE_KEYS`, fields a tracked file rewrites on its own: `custom-model-hosts.json` `lastDiscoveredAt` (the discovery poll), `users.json` `lastLoginAt` (every Basic login), `settings.json` `lastUsedCase` (a session started in another case), `intents.json` `recentPrompts`/`updatedAt` (every captured prompt), `docker-cases.json` `lastClaudeSessionId` (a `/clear` in a Docker case) and `webviews.json` `lastOpenedAt` (opening a web tab). Each was found the same way: one tick-driven rewrite made a snapshot per tick, and the 20 kept copies covered about 95 minutes. A new self-rewritten field in a tracked file needs an entry there and a row in the `it.each` table of `test/config-backup.test.ts`. The snapshot still stores the bytes and restore verifies the raw digest; only the comparison looks through these keys (so Compare calls `intents.json` unchanged when only prompts differ, yet a restore drops the prompts captured since).
⚠️ **One snapshot per real change.** The change check compares a digest of the file set with the newest manifest's `hash`, looking through `VOLATILE_KEYS`, fields a tracked file rewrites on its own: `custom-model-hosts.json` `lastDiscoveredAt` (the discovery poll), `users.json` `lastLoginAt` (every Basic login), `settings.json` `lastUsedCase` (a session started in another case) and `lastPickedCase` (a pick in a case picker), `intents.json` `recentPrompts`/`updatedAt` (every captured prompt), `docker-cases.json` `lastClaudeSessionId` (a `/clear` in a Docker case) and `webviews.json` `lastOpenedAt` (opening a web tab). Each was found the same way: one tick-driven rewrite made a snapshot per tick, and the 20 kept copies covered about 95 minutes. A new self-rewritten field in a tracked file needs an entry there and a row in the `it.each` table of `test/config-backup.test.ts`. The snapshot still stores the bytes and restore verifies the raw digest; only the comparison looks through these keys (so Compare calls `intents.json` unchanged when only prompts differ, yet a restore drops the prompts captured since).
⚠️ **Retention deletes only what this module made, and never the newest snapshot.** A folder counts only with the name pattern AND a parseable manifest of this origin, so a custom folder that also holds other things is safe to point at. A snapshot is aged from its SUCCESSOR's `createdAt` (its state was live until the next one was made): aging it by its own date deleted the last good copy on the first change after a quiet month, which is exactly the bad write the feature exists for. The keep count floor is 2 (`CONFIG_BACKUP_MIN_KEEP_COUNT`), since 1 deletes the state before every change. A new snapshot is never dated earlier than the newest one (`createdAt + 1`): one written while the clock read ahead (dual boot, a VM resumed with a stale clock) stayed newest after the correction, differed from every later pass, and made each one write a copy until real time caught up. The flip side, a clock once set to a future year, is a Troubleshooting entry (delete the future-dated folder).
+1 -1
View File
@@ -161,7 +161,7 @@ Codeman now ships a global **Startup Mode** picker (App Settings, Claude CLI tab
| Screenshots `/api/screenshots` (deprecated) | Deprecated: removed in a later MAJOR, so no per-user subdir is planned; the replacement `POST /api/sessions/:id/paste-image` is already session-scoped. Former plan: per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). **Implemented (1.42.0):** `PUT /api/settings`, `PUT /api/execution/model-config`, `PUT /api/config` and `PUT /api/cases/order` answer 403 `FORBIDDEN` to a non-admin before parsing anything; quick-start and Ralph loop start skip a non-admin's `lastUsedCase`; the browser skips those writes for a non-admin (`_settingsReadOnly()`, settings-ui.js) and keeps their own run mode and respawn presets on the device; a non-admin's `GET /api/settings` omits `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase` (`settingsForNonAdmin`). Per-user server settings: out of scope v1|
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). **Implemented (1.42.0):** `PUT /api/settings`, `PUT /api/execution/model-config`, `PUT /api/config` and `PUT /api/cases/order` answer 403 `FORBIDDEN` to a non-admin before parsing anything; quick-start and Ralph loop start skip a non-admin's `lastUsedCase`; the browser skips those writes for a non-admin (`_settingsReadOnly()`, settings-ui.js) and keeps their own run mode and respawn presets on the device; a non-admin's `GET /api/settings` omits `voiceSettings.apiKey`, `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings` (`settingsForNonAdmin`). Per-user server settings: out of scope v1|
| System ops (self-update, tunnel toggle, span-displays, docker image build) | Admin-only. **Implemented (1.42.0):** `POST /api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate` and `/api/cleanup-state` answer 403 `FORBIDDEN` to a non-admin; a non-admin's `POST /api/auth/revoke` revokes only their own login sessions and `GET /api/tunnel/info` lists only those |
| `getLightState` init snapshot | Filtered per connection. Actual contents to filter (verified): `sessions`, `scheduledRuns`, `respawnStatus`, `subagents`, `workflowRuns`, `planUsage` (host-plan telemetry: admin-only); `globalStats` stays coarse-global. Cron jobs are NOT in the snapshot (they have their own REST route; filter there). The snapshot is cached process-wide (`LIGHT_STATE_CACHE_TTL_MS`): either key the cache per role/user or filter AFTER the cache on each send |
+1 -1
View File
@@ -41,7 +41,7 @@ cron jobs.
A field a file rewrites on its own is not a change, and the comparison looks through it (the
snapshot still stores the file exactly as written, and a restore puts those fields back as they
were): `custom-model-hosts.json` `lastDiscoveredAt` (the model-discovery poll, every five
minutes), `users.json` `lastLoginAt`, `settings.json` `lastUsedCase`, `intents.json`
minutes), `users.json` `lastLoginAt`, `settings.json` `lastUsedCase` and `lastPickedCase`, `intents.json`
`recentPrompts` and `updatedAt` (every captured prompt) and `docker-cases.json`
`lastClaudeSessionId` (a Docker case's conversation id after a `/clear`) and `webviews.json`
`lastOpenedAt` (opening a web tab). Without that, every
+20
View File
@@ -78,6 +78,26 @@ sequences, so word jumps and history keys keep working.
| **Deepgram** | A Deepgram API key. | Nova-3, with automatic silence detection. |
| **Web Speech** | Nothing. | Browser-provided, quality varies. |
### Thinking pauses
In **App Settings → Voice → Stop after silence**, choose 3, 6, 15 or 30 seconds,
or **Manual** to keep listening until you tap the mic again (`Ctrl+Shift+V`).
**Provider default** uses 6 seconds for Claude and 3 seconds for Deepgram or Web
Speech. Final transcript events no longer stop Deepgram or Web Speech immediately:
they wait for the configured silence deadline. The timeout counts from the latest
transcript update. A browser can still end its native recognition run; extended
and Manual dictation restart it while preserving the text and silence deadline.
A silent run that lasts its full length never ends one, so 30 seconds and Manual
survive long pauses. Connection failures, microphone errors, or a recognizer that
keeps ending within a second of starting without hearing anything can still end
a recording.
On Chrome for Android, live words replace earlier partial results without repeating
them in the input. The browser microphone bars indicate speech activity; streaming
providers show audio levels. After Stop, dictation briefly waits for the final
words before inserting text. Reconnecting preserves the available words and resets
the mic so another recording can start.
### Dictating through your Claude login
Off by default; enable it in **App Settings → Voice**.
+13 -3
View File
@@ -248,9 +248,19 @@ A small native app that opens Codeman in its own window. Its first screen has tw
runs the installer on that machine over SSH.
On a MacBook Pro with a PROLED XDR display, whose lid logo lights up,
**View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of
your own for working, for waiting for you and for needing permission. All three are off
until you choose, and the menu only exists on such a Mac.
**View > Blink Apple Logo** makes the logo show what your agents are doing: it breathes while
they work, blinks when one waits for you and flashes three times when one needs permission.
Each state can take another style under **View > Apple Logo Blink Styles**. It is off until
you turn it on, and the menu only exists on such a Mac.
**Codeman > Keep This Mac Awake** stops the Mac from going to sleep while your agents work
on it (**While Agents Work**, plus 5 minutes after the last one stops), or for as long as
the app is open (**Always**). It is off until you choose, only acts for a Codeman running
on this Mac, needs no password, and ends when the app quits. On a MacBook it pauses on
battery unless you untick **Only When Plugged In**, and **With the Lid Closed…** keeps a
closed MacBook awake too: ticking it asks for your administrator password to install a
small helper, unticking it asks again and removes the helper, and it never acts on
battery.
### First launch
+4 -3
View File
@@ -247,8 +247,9 @@ Discord group for the two-way bot (token, IDs, thread and ping options; see
### Voice
Active provider and the engine behind it, insert mode, language, domain keywords to bias
recognition, the Deepgram API key, and the opt-in switch for transcribing through this
server's Claude login, with its live credential status. See
recognition, **Stop after silence** (provider default, 3, 6, 15 or 30 seconds, or Manual),
the Deepgram API key, and the opt-in switch for transcribing through this server's Claude
login, with its live credential status. See
[Input And Voice](Input-And-Voice).
### Shortcuts
@@ -258,7 +259,7 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
### System
`CLAUDE.md` template for new cases, default working directory, the image watcher, the prompt-cache keep-warm default (**Keep prompt caches warm**, off) and its cap (**Keep warm for at most**, 2 hours after a session's last prompt; see [Keeping Agents Running](Keeping-Agents-Running)), and
Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group runs
Cloudflare tunnel controls including the tunnel URL. **Sync cases across devices** (under Paths, off by default): Keeps each case's **Agent Teams** and **Opus 1M** options on the server (`caseSettings` in `settings.json`) and, when you return to a tab, switches it to the case you last chose on another device. Synced, off by default. Without it each browser keeps its own options for a case (the case list itself is always shared). The case names are stored in the shared settings file, so a non-admin in multi-user mode neither reads, writes nor follows them. Each save sends only that case and the server merges it, so devices saving different cases never overwrite each other; a case renamed in Codeman keeps its options, and deleting or unlinking it removes them. A save the server refuses leaves the choice on that device only until the next sync, which brings the server's copy back, and says so. Only a case picked in a case picker is followed, not one a quick-start or an agent's spawned worker used. If another device turns this off, this one stops following, and the server ignores case options sent while it is off. Turning it off leaves the stored options in `settings.json`, so turning it back on brings them back. A device that already has options for a case keeps them until a device changes that case's options while this is on. The **Diagnostics** group runs
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
tools with their versions and install hints (admin only in multi-user mode). In multi-user
mode, the **Users** administration entry is injected here.
+7
View File
@@ -19,6 +19,13 @@ not finish shows **Not fully scanned** rather than looking empty. A folder that
such as a network mount that went away, is skipped rather than waited on and is marked the same way,
so a search can miss what is inside it. The panel's Refresh button tries it again.
Each file row has a download arrow and a × that deletes the file after you confirm. Both show
when you point at the row, and always on a touch screen. Delete removes one file at a time,
never a folder, and refuses a symbolic link (deleting one from here would remove the file it
points to), anything under `.git`, and the sensitive files the viewer will not serve. It is not
offered for remote (SSH) sessions or for tmux sessions opened from outside Codeman. Deleting
from search results takes the row out of the results.
It renders what it can:
| Kind | Behaviour |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.50.0",
"version": "1.50.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.50.0",
"version": "1.50.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.50.0",
"version": "1.50.1",
"description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.",
"mcpName": "io.github.Ark0N/codeman",
"type": "module",
+1
View File
@@ -50,6 +50,7 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [
"NSGeometry",
"NSObject",
"NSOperation",
"NSProcessInfo",
"NSRange",
"NSSet",
"NSString",
+140 -35
View File
@@ -40,7 +40,8 @@ cannot do.
| **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Inside a rendered HTML preview or a web tab, a plain link to another site does nothing, while one that asks for a new window (`target="_blank"`, `window.open`) opens in your default browser within the same budget (see Known limits). |
| **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. |
| **A page that stops** | When the web content process of a window ends (a WebKit crash, or macOS taking the process back under memory pressure), the window loads its page again instead of staying blank. A second time within 30 seconds, the main window goes back to the connect screen and a pop-out closes, so a page that cannot stay up never becomes a reload loop. |
| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing, with a blink style of your own for each state: **View > Blink Apple Logo > While Working**, **Waiting for You (Yellow)** and **Needs Permission (Red)**. Off, Calm, Slow, Wink, Heartbeat, Fast, or Alarm, three sharp flashes for a state that needs you. All three are off until you choose. When several states hold, the most urgent one with a style shows: red, then yellow, then work. A style chosen in an earlier version stays as the one for work, and yellow and red start as Wink and Alarm. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. A logo you switched off yourself stays off, and if the setting changes while a blink runs, the blink ends and leaves it as it found it. While the app is closed nothing blinks, and macOS keeps the logo dark while the display sleeps or the lid is closed, so the blink pauses then and writes nothing. |
| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing. **View > Blink Apple Logo** is the one switch, off until you turn it on. The more the logo moves, the more it needs you: it breathes (a slow fade out and back in) while agents work, blinks every two seconds for a yellow tab and flashes three times, then rests, for a red one. Each state can take another style under **View > Apple Logo Blink Styles**: Off, Breathe, Calm, Slow, Wink, Heartbeat, Fast, or Alarm. Picking a style turns the switch on, and turning the switch on with every state off brings back the recommended three (Breathe, Calm, Alarm). When several states hold, the most urgent one with a style shows: red, then yellow, then work. Styles chosen in 1.50.0 are kept, with the switch on if any of them was. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. The logo moves on its own hardware fades, the ones [LogoLight](https://github.com/Ark0N/LogoLight) uses, so a blink writes no setting, and a logo you switched off yourself stays off. LogoLight (1.3 or later) steps aside while Codeman shows its agents on the logo, and takes the logo back afterwards, with whatever it showed before; while an older LogoLight runs, Codeman leaves the logo to it. While the app is closed nothing blinks. With the lid closed it just goes on while the Mac stays awake on its power adapter (**Keep This Mac Awake > With the Lid Closed…**, or an external display): macOS keeps the logo dark under a closed lid, so the app tells the logo the lid is open and the keyboard that it is closed, which stays dark, as LogoLight's *Lit with Lid Closed* does. The light sensor is covered then, so the logo glows as it would in a dark room. The line under **Apple Logo Blink Styles** says what the closed lid did last (blinking, or why not). On battery, and while the display only sleeps with the lid open, the blink pauses and writes nothing. |
| **Keep awake** | **Codeman > Keep This Mac Awake** keeps the Mac from going to sleep while Codeman's agents work on it: **While Agents Work** (an agent at work, and for 5 minutes after the last one stopped, so the gap between two turns or a reply from your phone does not let it drop off), or **Always** (while the app is open). Off until you choose. Only for a Codeman on this Mac (`127.0.0.1`, `localhost`, or one of this Mac's own addresses); for a server on another machine the menu says so. The display still sleeps. No password is needed for this part: the app holds a power assertion of its own, which macOS shows in Activity Monitor and in `pmset -g assertions` as *Codeman keeps this Mac awake while its agents work*, and which ends with the app, a crash or a Force Quit included. On a MacBook, **Only When Plugged In** (on by default) pauses it on battery, and **With the Lid Closed…** keeps a closed MacBook awake as well: nothing an app holds can do that, so ticking it asks for your administrator password and installs a small helper (see Decisions), and unticking it asks again and removes the helper. The lid is never kept awake on battery, whatever the settings, and while the lid is covered *Sleep* in the Apple menu does nothing. A line at the bottom of the menu says what it does right now. It lives in the app, so it ends when the app quits: Codeman keeps running as a LaunchAgent, but nothing keeps the Mac awake then. That is why, while it watches a Codeman on this Mac, closing the Codeman window hides the app instead of quitting it, as Cmd+H does, also while it is paused (on battery, say), since it carries on by itself: a click on its Dock icon brings it back, and only Cmd+Q (or Quit from the Dock) ends it. With no server open, or one on another machine, closing the window quits. |
| **Window** | Size, position and full-screen state are remembered. A saved window that hangs off the screen it opens on (it was last on a larger display that is gone, say) is pulled back on and shrunk to fit; one whose title bar is on no screen at all opens at the default size instead. Clicking the Dock icon brings back a minimized window. |
## Build (on a Mac)
@@ -147,6 +148,30 @@ classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
- `codeman web -d` gets the port and base path as separate arguments, never as
shell text.
**Keeping a closed MacBook awake takes a root helper, installed on request.**
macOS sleeps on lid close whatever an app asks: `caffeinate` and every power
assertion stop idle sleep only. `pmset -a disablesleep 1` keeps a closed MacBook
awake, but it is a machine-wide root setting with no owner process, so nothing
would undo it after a crash. So ticking *With the Lid Closed…* installs a
LaunchDaemon (`com.getcodeman.keepawake`) that runs `macos/keep-awake-lid.sh`
every 20 seconds. The script sets `disablesleep` only while a request file next
to `desktop.json` is younger than 2 minutes, is not a symlink, names a live
process of the file's owner, and `pmset -g batt` says the Mac runs on its power
adapter. The app writes its own pid there and rewrites it every 30 seconds while
it wants the lid covered, so an app that quits or crashes is a dead pid and the
lid is released at the next run. The script undoes only a `disablesleep` it set
itself (a marker file), never an administrator's own. The app writes the script
and the plist itself, as root, straight into `/Library/Application
Support/Codeman` and `/Library/LaunchDaemons` from the copies built into it
(base64 in the root command, so nothing user-writable is copied and no file
content needs quoting), behind macOS's administrator prompt (`do shell script
... with administrator privileges` through `osascript`, so the event loop keeps
running while the prompt is open). The idle-sleep part needs none of this: an
`NSProcessInfo` activity with `IdleSystemSleepDisabled`, released by the kernel
with the process. While the app watches for work but holds nothing, a second
kind of activity keeps App Nap from slowing its look at the server, and still
allows sleep.
**The app runs the installer itself; Terminal is the fallback.** `install.sh
--no-admin` puts Node.js and tmux in the home folder, so a This Mac install needs
no password and therefore no terminal, and the two questions only a person can
@@ -225,31 +250,93 @@ If that probe is refused or rate limited, the connect screen comes back; the
app does not send the refused login again. Keeping no copy of the login in
WebKit has a cost here: after a server restart, each request that arrives
without a cookie gets a 401 before the
login goes out, and those count until one succeeds. Many windows reconnecting
at once can push a request past 10, and that request then fails with a 429
instead of being answered. A refusal that lands while you are in another app
does not bring Codeman forward: the connect screen loads where the window is,
and the Dock icon bounces.
login goes out, and those count until on**The logo moves on its own hardware fades, from a child process, through CoreBrightness.**
Apple documents one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)):
`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`, an on/off setting that
`corebrightnessd` keeps in root's preferences. A process of the logged-in user can reach the
daemon without an administrator through `BrightnessSystemClient` in the private CoreBrightness
framework, and 1.50.0 blinked by switching that setting there. The blink now uses two other
properties of the same client, which [LogoLight](https://github.com/Ark0N/LogoLight) (MIT, its
`docs/corebrightnessd.md`) measured on the real logo and `src/logo.rs` reimplements:
`LEPWMFactorFadeDuration` sets how long the next fade takes, and `DisplayLogicalState` 1
(Blank) fades the logo to dark and 2 (Unblank) back to lit, on the LED's own spring curve.
Nothing of that is saved, so a blink writes no setting and leaves nothing behind a restart;
`LEBacklightMode` is only read.
**The logo blinks from a child process, through CoreBrightness.** Apple documents
one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)):
`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`. Doing that
for every blink would need a root helper and an administrator password. The same
setting is also a property of `BrightnessSystemClient` in the private
CoreBrightness framework, and a process of the logged-in user can set it there,
which is what `src/logo.rs` does. Measured on a Mac17,13 with macOS 27.2 from an
ad-hoc signed binary: `corebrightnessd` logs the logo dark about a millisecond
after the write, with no fade, and the NVRAM variable `bklb-backlight-enabled`
keeps its value while the logo is dark.
The daemon decides the rest. Writing the direction the logo already heads in is ignored, and
the state cannot be read back, so the child keeps track of it and, when it does not know,
first turns the logo round with a fade too slow to see. The keyboard backlight follows
`DisplayLogicalState` too, so after every Blank the keyboards alone are told Unblank
(`setProperty:withKey:keyboardID:`), only while the built-in display is awake
(`CGDisplayIsAsleep`); when it sleeps they are told Blank, and Unblank when it wakes. A rise
from exactly dark waits for the display to show a frame, up to half a second with nothing
changing on screen. So the short dark beats (Alarm's, Heartbeat's, Wink's, Fast's) turn round
while their fall is still under way and never quite reach dark, and a long one (Calm's,
Slow's, the breath's rest) times its light from when the rise starts.
The setting is system-wide and survives a reboot, so the app must not end with the
logo dark, and the release profile has `panic = "abort"`, so no cleanup code of the
app can be relied on. So all of it runs as this same binary with
`--logo-blink`, for as long as any style is chosen and a server is open, tied to the app
by nothing but a pipe: when the pipe closes, however the app ended, it puts the logo
back and exits. Switching it off closes the same pipe, so the path a crash
takes is the one used every day. A marker file
(`logo-blink`, next to `desktop.json`) covers what the child cannot survive itself:
With the lid closed the daemon tells every module so (`ClamshellUpdate` 1), and the logo's
target is then 0 whatever `DisplayLogicalState` says. A client's `ClamshellUpdate` reaches the
same modules, and LogoLight measured on the real MacBook what follows from that, first with an
external display and, for its 1.3.1, on a Mac kept awake with none:
- Kept awake with the lid closed and no other display, the built-in display stays online and
`CGDisplayIsAsleep` says 0. So the lid sensor (`AppleClamshellState` on `IOPMrootDomain`)
decides that the logo is out of sight, not the display list.
- Once the daemon has handled the lid (`CBClamshellState` reads 1, a moment after the sensor),
`ClamshellUpdate` 0 relights the logo and fades work as with the lid open. The child then
writes `ClamshellUpdate` 1 to each keyboard alone, which stays dark, and to each display in
`CBDisplayList` alone (`setProperty:withKey:andDisplay:`): told the lid is open, True Tone
adapts an external display to the covered light sensor and tints it warm and dim.
- The daemon sends the real lid state again when a display or a keyboard comes up. Every beat
ends lit, so a logo found dark at the start of the next one is told again.
- `DisplayPowerStateEvent` is never written. A client's DidSleep keeps the logo dark, with the
lid open too, until someone writes DidWake.
The child does this only on the power adapter (on battery a closed MacBook sleeps, and a dark
wake must not light the logo in a bag) and only with every keyboard reachable. The first rise
has to move the logo within 1.5 s. If it does not, the logo is told again, three times in a
row and then every half minute, and the menu line says it stayed dark. Ending a blink with the
lid closed hands the logo back to normal (the automatic duration, then Unblank, which a held
level needs) and then writes `ClamshellUpdate` 1, since normal for a closed lid is dark; the
marker covers a child killed in between. When the lid opens, the daemon tells every module
itself and nothing is written. What the closed lid did last (blinking, stayed dark, nothing to
show with no agent at work, not tried and why, or that the Mac went to sleep under it, which
the child sees as wall-clock time that passed while its monotonic clock stood still) goes to a
file next to `desktop.json` (`logo-lid`, with the readings behind it on its second line), and
the app shows its first line as **Lid Closed: …** under View > Apple Logo Blink Styles, or
says nothing watches the lid while no child runs.
Whether the Mac stays awake under the lid is not the logo's business: Keep This Mac Awake's
lid helper does that (`pmset disablesleep`, which holds the Mac at full wake), or LogoLight's
own Lit with Lid Closed, or an external display. An assertion against system sleep alone would
not do: LogoLight saw the Mac enter a dark wake with it, where the logo stays dark.
LogoLight drives the logo the same way, so the two would fight over every fade. The marker the
child keeps next to the app's settings while it moves the logo (`logo-fade`, which also lets the
next launch put back a logo a killed child left dark) holds the child's process ID, goes down
right before the first write and comes off once the logo is back to normal. A LogoLight that says
`LogoLightStepsAsideForCodeman` in its Info.plist (read from the running app's bundle, by its
process ID) looks at that file every second and pauses while it names a running
`codeman-desktop`, so the child waits two seconds after writing it before its first fade.
LogoLight takes the logo back only once the file has been gone for a second and a half, so a
blink that starts within a second of the last one's end (work, then a permission) does not
wait again. An older LogoLight is left the logo, as before. LogoLight ships its side since
1.3.0. With the lid closed, the two tell the logo the same thing (the lid is open), so a
LogoLight that lights the logo under the lid and a blink do not undo each other; when a blink
ends there the logo goes dark, and LogoLight lights it again as it takes it back.
The app must not end with the logo dark, and the release profile has `panic = "abort"`, so no
cleanup code of the app can be relied on. So all of it runs as this same binary with
`--logo-blink`, for as long as the switch is on and a server is open, tied to the app by
nothing but a pipe: when the pipe closes, however the app ended, it puts the logo back (lit,
with the daemon's automatic fade duration) and exits. Switching it off closes the same pipe,
so the path a crash takes is the one used every day. A marker file (`logo-fade`, next to
`desktop.json`) covers what the child cannot survive itself: at the next launch the app puts
back a logo a blink left dark, and with 1.50.0's marker (`logo-blink`) it switches back on a
logo that version left off. The framework is loaded at run time and never linked, and
`LEBacklightMode` reading nil (a Mac mini answers nil) is what hides the option, so a macOS
that drops it loses this feature and nothing else.
e child cannot survive itself:
at the next launch the app puts back a logo a blink left dark. The framework is
loaded at run time and never linked, and `LEBacklightMode` reading nil (a Mac mini
answers nil) is what hides the option, so a macOS that drops it loses this feature
@@ -308,17 +395,35 @@ crosses, so the login is never on a command line.
look as a failed login, like any look at a server the app has no login for.
*Check again* asks again.
- **The logo blink rests on a private macOS interface.** Apple documents the
setting, not the way the app reaches it, so a macOS update can take it away;
the menu then disappears. Every change of the logo writes `LEBacklightMode`,
from about once every two seconds (*Slow*, *Wink*) to about three times a
second (*Fast*), which `corebrightnessd` keeps in root's preferences. The
setting is on or off, so these styles are rhythms of hard changes. After a
blink the key stays there rather than being absent as on a fresh Mac. That
reads the same to macOS; `sudo defaults delete com.apple.CoreBrightness
LEBacklightMode` removes it. After a power cut in a
dark phase the logo stays dark until the app next starts. `api/sessions` does
not say which turns are Codeman's own, so with prompt-cache keep-warm on, a
keep-alive turn blinks too.
on/off setting, not the fades the app uses, so a macOS update can take them
away. The menu disappears if `LEBacklightMode` goes; if only the fade keys
change, the logo stops moving. A long dark beat (*Calm*, *Slow*, the rest in
*Breathe*) can be up to half a second longer than its style says, while the
display waits for a frame before the logo rises. Every Blank dims the
keyboard backlight a little before it is told to stay lit. A child killed
outright (SIGKILL) leaves the logo where it was, perhaps dark, until the
display next sleeps and wakes or the app next starts. Another app that drives
the logo the same way and is not LogoLight is not recognised, and the two
fight. The handover with a LogoLight that steps aside was checked against a
stand-in process carrying LogoLight 1.3.1's Info.plist and against LogoLight's
own tests, and ran in that release candidate next to LogoLight 1.3.1; its
timing on a real logo was not measured. `api/sessions` does not say which turns are Codeman's own, so with
prompt-cache keep-warm on, a keep-alive turn blinks too.
- **Keeping the lid awake has not been seen on a real closed MacBook yet.**
The helper ran every case against a stand-in `pmset`, the install and removal
commands ran in a temporary folder with stand-ins for `chown` and `launchctl`,
and the power assertion was measured on a Mac mini (it appears with its name
while agents work, and goes with a clean end and with a SIGKILL). Whether a
closed MacBook goes to sleep at once when the helper lets go (the app quit, an
agent finished, the adapter came out) has not been watched. The helper is
machine-wide: on a Mac where two users install it, the last install wins.
- **Blinking with the lid closed was seen on one MacBook Pro so far** (macOS
27.2, on power, a release candidate of 1.50.1 on 2026-11-10; the method is
LogoLight's, which its author measured for LogoLight 1.3.1). Nothing was
measured in that run, it was watched. Not checked, by LogoLight or here: the
built-in display's brightness when the lid opens again, and an Apple display
with its own light sensor. If the logo does not move under a closed lid, the
menu line says "stayed dark".
- **Self-signed HTTPS is refused.** A server started with `codeman web --https`
uses a self-signed certificate, and WKWebView rejects it (wry has no trust
override). Use `http://127.0.0.1:3000` for a server on this Mac. For another
+74
View File
@@ -0,0 +1,74 @@
#!/bin/bash
# keep-awake-lid.sh: the root helper behind "Keep This Mac Awake > Also With the Lid
# Closed" in the Codeman app for Mac.
#
# The app keeps the Mac from idle sleep on its own, with no root (a power assertion).
# Closing the lid is another matter: only `pmset -a disablesleep 1` keeps a closed
# MacBook awake, and that is a machine-wide root setting with no owner process. So this
# helper, run by the com.getcodeman.keepawake LaunchDaemon every 20 seconds, applies it
# while the app asks for it and undoes it when the app stops asking:
#
# - The app writes its pid to the request file, and rewrites it every 30 seconds, while
# it wants the lid covered.
# - The request counts only while the file is fresh (under 2 minutes old), is not a
# symlink, its pid is alive, that process belongs to the file's owner, and the Mac
# runs on its power adapter. An app that quits or crashes is a dead pid, so the lid
# is released at the next run.
# - Never on battery, whatever the app says: a closed MacBook that cannot sleep in a
# bag only heats up and drains.
# - It undoes only a disablesleep it set itself (the .owned file), so an
# administrator's own `pmset -a disablesleep 1` is never switched off.
#
# The app writes this file to a ROOT-OWNED path itself, behind macOS's administrator
# prompt; a root daemon must never run a file a user can edit.
#
# Usage: keep-awake-lid.sh <request-file>
# Test hooks (set only by the test suite; launchd passes no environment of the user's):
# CODEMAN_KEEPAWAKE_PMSET, CODEMAN_KEEPAWAKE_STATE_DIR.
#
# bash 3.2 (what macOS ships) and BSD tools only.
set -u
REQ="${1:?usage: keep-awake-lid.sh <request-file>}"
PMSET="${CODEMAN_KEEPAWAKE_PMSET:-/usr/bin/pmset}"
STATE_DIR="${CODEMAN_KEEPAWAKE_STATE_DIR:-/Library/Application Support/Codeman}"
OWNED="$STATE_DIR/keep-awake.owned"
want=0
# -L: refuse a symlink (root must not be steered to read some other file).
# find -mmin -2: modified within the last 2 minutes (BSD and GNU find both support it).
if [ -f "$REQ" ] && [ ! -L "$REQ" ] && [ -n "$(find "$REQ" -mmin -2 2>/dev/null)" ]; then
pid=$(head -c 32 "$REQ" 2>/dev/null | tr -dc '0-9')
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
file_uid=$(ls -ln "$REQ" 2>/dev/null | awk '{print $3}')
pid_uid=$(ps -o uid= -p "$pid" 2>/dev/null | tr -d ' ')
if [ -n "$file_uid" ] && [ "$file_uid" = "$pid_uid" ]; then
want=1
fi
fi
fi
# The power adapter, or nothing: an unreadable answer counts as battery.
if [ "$want" = "1" ] && ! "$PMSET" -g batt 2>/dev/null | grep -q "'AC Power'"; then
want=0
fi
current=$("$PMSET" -g 2>/dev/null | awk '/SleepDisabled/ {print $2; exit}')
[ -n "$current" ] || current=0
if [ "$want" = "1" ]; then
# Already 1 without our marker means an administrator set it: leave it and do not
# claim it, so releasing later never switches their setting off.
if [ "$current" != "1" ]; then
if "$PMSET" -a disablesleep 1; then
mkdir -p "$STATE_DIR" && : > "$OWNED"
fi
fi
elif [ -f "$OWNED" ]; then
if [ "$current" = "1" ]; then
"$PMSET" -a disablesleep 0 || exit 1
fi
rm -f "$OWNED"
fi
exit 0
+47 -5
View File
@@ -3,6 +3,7 @@
//! (`~/Library/Application Support/Codeman/desktop.json` on macOS). Passwords
//! never go here; they live in the Keychain (see `credentials`).
use crate::keep_awake;
use crate::logo::BlinkStyles;
use serde::{Deserialize, Serialize};
use std::fs;
@@ -44,11 +45,16 @@ pub struct Config {
pub auto_connect: bool,
/// Always open in full screen (otherwise the last state is restored).
pub start_fullscreen: bool,
/// What the lit Apple logo does for each state of the agents (logo.rs). The
/// menu only shows on a Mac that has one. A value this version does not know
/// reads as off: a bad style must not make the whole file unreadable.
/// Whether the lit Apple logo shows what the agents are doing, and its style
/// for each state (logo.rs). The menu only shows on a Mac that has one. A
/// value this version does not know reads as off: a bad style must not make
/// the whole file unreadable.
#[serde(deserialize_with = "lenient_blink")]
pub logo_blink: BlinkStyles,
/// Keep This Mac Awake (keep_awake.rs): when, only on the power adapter, and
/// with the lid closed too. Read field by field, like `logo_blink`.
#[serde(deserialize_with = "lenient_keep_awake")]
pub keep_awake: keep_awake::Settings,
/// Anything that is not a whole frame reads as None: a bad frame must not
/// make the whole file unreadable, which would cost the server list.
#[serde(deserialize_with = "lenient_window")]
@@ -64,6 +70,10 @@ fn lenient_blink<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BlinkStyles,
Ok(BlinkStyles::read(&serde_json::Value::deserialize(d)?))
}
fn lenient_keep_awake<'de, D: serde::Deserializer<'de>>(d: D) -> Result<keep_awake::Settings, D::Error> {
Ok(keep_awake::Settings::read(&serde_json::Value::deserialize(d)?))
}
impl Default for Config {
fn default() -> Self {
Config {
@@ -71,6 +81,7 @@ impl Default for Config {
auto_connect: true,
start_fullscreen: false,
logo_blink: BlinkStyles::default(),
keep_awake: keep_awake::Settings::default(),
window: None,
}
}
@@ -415,9 +426,12 @@ mod tests {
use crate::logo::BlinkStyle;
let mut c = Config::default();
assert_eq!(c.logo_blink, BlinkStyles::default());
c.logo_blink.needs_you = BlinkStyle::Alarm;
assert!(!c.logo_blink.enabled, "off until the user turns it on");
c.logo_blink.enabled = true;
c.logo_blink.needs_you = BlinkStyle::Wink;
let text = serde_json::to_string(&c).unwrap();
assert!(text.contains(r#""needsYou":"alarm""#), "{text}");
assert!(text.contains(r#""needsYou":"wink""#), "{text}");
assert!(text.contains(r#""enabled":true"#), "{text}");
assert_eq!(serde_json::from_str::<Config>(&text).unwrap().logo_blink, c.logo_blink);
for bad in [
r#"{"logoBlink":3}"#,
@@ -429,11 +443,39 @@ mod tests {
}
}
#[test]
fn keep_awake_round_trips_and_a_bad_value_never_costs_the_file() {
use crate::keep_awake::{Mode, Settings};
let mut c = Config::default();
assert_eq!(c.keep_awake, Settings::default());
c.keep_awake.mode = Mode::WhileWorking;
c.keep_awake.lid = true;
let text = serde_json::to_string(&c).unwrap();
assert!(
text.contains(r#""keepAwake":{"mode":"whileWorking","acOnly":true,"lid":true}"#),
"{text}"
);
assert_eq!(serde_json::from_str::<Config>(&text).unwrap().keep_awake, c.keep_awake);
for bad in [
r#"{"keepAwake":3}"#,
r#"{"keepAwake":null}"#,
r#"{"keepAwake":"always"}"#,
] {
let c: Config = serde_json::from_str(bad).unwrap();
assert_eq!(c.keep_awake, Settings::default(), "{bad}");
}
// A file from before the setting: every other field still reads.
let c: Config = serde_json::from_str(r#"{"startFullscreen":true}"#).unwrap();
assert!(c.start_fullscreen);
assert_eq!(c.keep_awake, Settings::default());
}
#[test]
fn the_style_a_mac_saved_before_there_were_states_is_kept() {
use crate::logo::BlinkStyle;
let c: Config = serde_json::from_str(r#"{"logoBlink":"heartbeat"}"#).unwrap();
assert_eq!(c.logo_blink.working, BlinkStyle::Heartbeat);
assert!(c.logo_blink.enabled);
}
#[test]
File diff suppressed because it is too large. Load diff
+2680 -360
View File
File diff suppressed because it is too large. Load diff
+18
View File
@@ -222,6 +222,13 @@ pub fn content_size(window: &tao::window::Window) -> Option<(f64, f64)> {
imp::content_size(window)
}
/// An app-modal message with an OK button, for something the user asked the app
/// itself to do that failed. Main thread only; does nothing elsewhere.
#[cfg(target_os = "macos")]
pub fn show_message(title: &str, detail: &str) {
imp::show_message(title, detail);
}
#[cfg(target_os = "macos")]
mod imp {
use std::cell::{Cell, RefCell};
@@ -456,6 +463,17 @@ mod imp {
}
}
pub(super) fn show_message(title: &str, detail: &str) {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let alert = NSAlert::new(mtm);
alert.setMessageText(&NSString::from_str(title));
alert.setInformativeText(&NSString::from_str(detail));
let _ = alert.addButtonWithTitle(&NSString::from_str("OK"));
alert.runModal();
}
pub(super) fn content_size(window: &tao::window::Window) -> Option<(f64, f64)> {
// NSWindow is main-thread only.
MainThreadMarker::new()?;
+284 -24
View File
@@ -27,6 +27,7 @@ mod assets;
mod config;
mod credentials;
mod gui_install;
mod keep_awake;
mod local_server;
mod login;
mod logo;
@@ -110,6 +111,7 @@ enter it again (a single-user Codeman accepts the right password at once), or wa
const KEYCHAIN_UNREADABLE: &str = "The saved password could not be read from the Keychain. Enter it again.";
const PAGE_KEEPS_STOPPING: &str = "Codeman's page stopped twice in a row, so it was closed. Connect again.";
const LOGO_SWITCHED_OFF: &str = "The Apple logo is switched off on this Mac, so it cannot blink.";
const LOGO_DRIVEN_ELSEWHERE: &str = "An older LogoLight is driving the Apple logo, so Codeman leaves it alone. Update LogoLight to 1.3 or later (or quit it) to see your agents on it.";
/// What a download's toast calls a file whose name is not known.
const UNNAMED_FILE: &str = "the file";
const START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address.";
@@ -182,6 +184,20 @@ enum UserEvent {
/// install opened in Terminal, the install command copied): the message
/// goes to the page if it is still the one choosing a server.
ToConnectPage(serde_json::Value),
/// The keep-awake worker's status line, for the value numbered `generation`
/// (keep_awake::KeepAwake::is_current).
KeepAwakeStatus {
generation: u64,
text: String,
},
/// The administrator prompt for the lid helper was answered, and its command
/// ran (keep_awake::install_helper, keep_awake::remove_helper).
LidHelperDone {
install: bool,
outcome: keep_awake::AdminOutcome,
},
/// What the closed lid did with the logo last (logo::watch_lid_status).
LogoLidStatus(String),
}
struct ProbeDone {
@@ -318,6 +334,10 @@ struct App {
fullscreen_retry: Option<(Instant, u8)>,
/// The child that watches the connected server and blinks the logo.
logo_watch: logo::Watcher,
/// The worker that keeps this Mac awake while its agents work.
keep_awake: keep_awake::KeepAwake,
/// An administrator prompt for the lid helper is open.
lid_helper_busy: bool,
}
fn main() {
@@ -370,6 +390,7 @@ fn main() {
let mut windows = HashMap::new();
windows.insert(main_id, AppWindow::new(webview, window, Kind::Main));
let keep_awake_proxy = proxy.clone();
let mut app = App {
proxy,
config,
@@ -397,8 +418,19 @@ fn main() {
add_agent_on_load: false,
fullscreen_retry: wants_fullscreen.then(|| (Instant::now() + FULLSCREEN_FIRST_CHECK, 0)),
logo_watch: logo::Watcher::default(),
keep_awake: keep_awake::KeepAwake::new(move |generation, text| {
let _ = keep_awake_proxy.send_event(UserEvent::KeepAwakeStatus { generation, text });
}),
lid_helper_busy: false,
};
if app.menu.logo_lid.is_some() {
let lid_proxy = app.proxy.clone();
logo::watch_lid_status(move |line| {
let _ = lid_proxy.send_event(UserEvent::LogoLidStatus(line));
});
}
event_loop.run(move |event, target, control_flow| app.handle(event, target, control_flow));
}
@@ -703,6 +735,18 @@ fn build_webview(
Ok(webview)
}
/// Hides the whole app, as Cmd+H does. A click on the Dock icon (or Cmd+Tab)
/// shows it again with every window as it was, a full-screen one included,
/// which hiding the main window alone would not do.
#[cfg(target_os = "macos")]
fn hide_app(target: &EventLoopWindowTarget<UserEvent>) {
use tao::platform::macos::EventLoopWindowTargetExtMacOS;
target.hide_application();
}
#[cfg(not(target_os = "macos"))]
fn hide_app(_target: &EventLoopWindowTarget<UserEvent>) {}
impl App {
fn handle(
&mut self,
@@ -719,17 +763,20 @@ impl App {
self.menu.help_menu.set_as_help_menu_for_nsapp();
}
}
Event::WindowEvent { window_id, event, .. } => self.on_window_event(window_id, event, control_flow),
Event::WindowEvent { window_id, event, .. } => self.on_window_event(window_id, event, target, control_flow),
Event::UserEvent(event) => self.on_user_event(event, target),
// A click on the Dock icon while no window is visible, which is
// the main window minimized: tao only reports it, so bring the
// window back here.
// window back here. An app hidden by closing its window
// (`hide_app`) needs nothing here: AppKit unhides it, with every
// window as it was, before it reports the click.
Event::Reopen {
has_visible_windows: false,
..
} => self.show_main_window(),
Event::MainEventsCleared => self.on_events_cleared(control_flow),
Event::LoopDestroyed => {
self.keep_awake.shutdown();
self.record_main_frame();
self.save_config();
}
@@ -790,17 +837,34 @@ impl App {
self.fullscreen_retry = Some((now + FULLSCREEN_RETRY_EVERY, attempts + 1));
}
fn on_window_event(&mut self, id: WindowId, event: WindowEvent, control_flow: &mut ControlFlow) {
fn on_window_event(
&mut self,
id: WindowId,
event: WindowEvent,
target: &EventLoopWindowTarget<UserEvent>,
control_flow: &mut ControlFlow,
) {
match event {
WindowEvent::CloseRequested if id != self.main_id => self.remove_window(id),
WindowEvent::CloseRequested => {
if id == self.main_id {
self.record_main_frame();
self.save_config();
let server = self.phase.borrow().loaded().cloned();
let mode = self.config.keep_awake.mode;
if keep_awake::available()
&& keep_awake::close_hides_app(mode, server.as_ref(), keep_awake::local_addresses)
{
// Keep This Mac Awake ends with the app, so closing the
// window while it watches a Codeman on this Mac hides
// Codeman instead, as Cmd+H does (full screen and pop-outs
// included): the Mac stays awake, a click on the Dock icon
// brings it back, and Cmd+Q still quits.
hide_app(target);
} else {
// Closing the main window quits; the server and its
// sessions keep running, so nothing is lost.
self.record_main_frame();
self.save_config();
self.keep_awake.shutdown();
*control_flow = ControlFlow::Exit;
} else {
self.remove_window(id);
}
}
WindowEvent::Moved(_) | WindowEvent::Resized(_) if id == self.main_id => self.record_main_frame(),
@@ -821,6 +885,7 @@ impl App {
self.to_connect_page(msg);
}
}
UserEvent::LogoLidStatus(line) => self.show_lid_status(line),
UserEvent::ServerStartDone {
generation,
url,
@@ -875,6 +940,7 @@ impl App {
*self.phase.borrow_mut() = next;
}
self.sync_logo_watch();
self.sync_keep_awake();
}
if let Some(w) = self.windows.get_mut(&window) {
w.committed = Url::parse(&url).ok();
@@ -937,6 +1003,14 @@ impl App {
}
UserEvent::AuthRejected => self.recheck_login(),
UserEvent::ContentProcessGone { window } => self.on_content_process_gone(window),
UserEvent::KeepAwakeStatus { generation, text } => {
if self.keep_awake.is_current(generation) {
if let Some(items) = &self.menu.keep_awake {
items.status.set_text(&text);
}
}
}
UserEvent::LidHelperDone { install, outcome } => self.on_lid_helper_done(install, outcome),
}
}
@@ -1030,35 +1104,214 @@ impl App {
}
}
/// The logo is watched while any style is chosen and a server is open.
/// The logo is watched while the switch is on, a state has a style and a
/// server is open.
fn sync_logo_watch(&mut self) {
let styles = self.config.logo_blink;
let phase = self.phase.borrow();
let server = phase.loaded().filter(|_| styles.any() && logo::available());
let server = phase.loaded().filter(|_| styles.active() && logo::available());
self.logo_watch
.sync(server.map(|url| (url.as_str(), self.auth.as_deref(), styles)));
drop(phase);
self.show_lid_status(logo::lid_status_line());
}
/// The line under the logo's styles: what the closed lid did last, or that
/// nothing watches it now.
fn show_lid_status(&self, line: String) {
if let Some(item) = &self.menu.logo_lid {
if self.logo_watch.watching() {
item.set_text(line);
} else {
item.set_text(logo::LID_NOT_WATCHED);
}
}
}
fn choose_logo_blink(&mut self, signal: Signal, style: BlinkStyle) {
// A click flips the item it landed on, the chosen one included, so all
// of that state's are set.
for (item_signal, item_style, item) in &self.menu.logo_styles {
if *item_signal == signal {
item.set_checked(*item_style == style);
}
}
let was_off = !self.config.logo_blink.any();
let was_active = self.config.logo_blink.active();
self.config.logo_blink.set(signal, style);
// Picking a style is asking for the logo to show it.
if style != BlinkStyle::Off {
self.config.logo_blink.enabled = true;
}
self.logo_blink_changed(was_active);
}
/// The one switch. Turned on with every state off, it brings the recommended
/// styles, so that on always shows something.
fn toggle_logo_blink(&mut self) {
let was_active = self.config.logo_blink.active();
let on = !self.config.logo_blink.enabled;
self.config.logo_blink.enabled = on;
if on && !self.config.logo_blink.any_style() {
self.config.logo_blink = logo::BlinkStyles::RECOMMENDED;
}
self.logo_blink_changed(was_active);
}
fn logo_blink_changed(&mut self, was_active: bool) {
// A click flips the item it landed on, so every item is set from the
// settings, the clicked one included.
let styles = self.config.logo_blink;
if let Some(switch) = &self.menu.logo_switch {
switch.set_checked(styles.enabled);
}
for (signal, style, item) in &self.menu.logo_styles {
item.set_checked(styles.get(*signal) == *style);
}
self.config_dirty = true;
self.sync_logo_watch();
// Only from off: with a style running, a dark logo may be our own.
if was_off && style != BlinkStyle::Off && logo::switched_off() {
if let Some(main) = self.windows.get(&self.main_id) {
let _ = main
.webview
.evaluate_script(&assets::toast_script(LOGO_SWITCHED_OFF, "warning"));
// Only from off: with a blink running, a dark logo may be our own.
if was_active || !styles.active() {
return;
}
let warning = if logo::switched_off() {
LOGO_SWITCHED_OFF
} else if logo::driven_elsewhere() {
LOGO_DRIVEN_ELSEWHERE
} else {
return;
};
if let Some(main) = self.windows.get(&self.main_id) {
let _ = main.webview.evaluate_script(&assets::toast_script(warning, "warning"));
}
}
/// Keep-awake watches the open server while a mode is chosen. Whether that
/// server is this Mac is the worker's to find out.
fn sync_keep_awake(&mut self) {
let settings = self.config.keep_awake;
let server = self.phase.borrow().loaded().cloned();
let watch = server
.filter(|_| settings.mode != keep_awake::Mode::Off)
.map(|server| keep_awake::Watch {
server,
authorization: self.auth.clone(),
settings,
});
let idle = match (settings.mode, &watch) {
(keep_awake::Mode::Off, _) => Some(keep_awake::OFF_TEXT),
(_, None) => Some(keep_awake::NO_SERVER_TEXT),
_ => None,
};
self.keep_awake.sync(watch);
if let (Some(text), Some(items)) = (idle, &self.menu.keep_awake) {
items.status.set_text(text);
}
}
fn choose_keep_awake_mode(&mut self, mode: keep_awake::Mode) {
if let Some(items) = &self.menu.keep_awake {
// A click flips the item it landed on, the chosen one included.
for (item_mode, item) in &items.modes {
item.set_checked(*item_mode == mode);
}
}
self.config.keep_awake.mode = mode;
self.config_dirty = true;
self.sync_keep_awake();
}
fn toggle_keep_awake_ac_only(&mut self) {
let Some(item) = self.menu.keep_awake.as_ref().and_then(|items| items.ac_only.as_ref()) else {
return;
};
self.config.keep_awake.ac_only = item.is_checked();
self.config_dirty = true;
self.sync_keep_awake();
}
/// The lid item is the helper: ticking it installs the helper behind the
/// administrator prompt (unless this version's is already in), unticking it removes
/// it behind another. The setting changes only once the prompt's command ran, so a
/// cancelled or failed prompt leaves the item as it was.
fn toggle_keep_awake_lid(&mut self) {
use keep_awake::HelperState;
let Some(wanted) = self
.menu
.keep_awake
.as_ref()
.and_then(|items| items.lid.as_ref())
.map(|item| item.is_checked())
else {
return;
};
let state =
keep_awake::request_path().map_or(HelperState::Missing, |request| keep_awake::helper_state(&request));
let prompt = match (wanted, state) {
_ if self.lid_helper_busy => None,
(true, HelperState::Installed) | (false, HelperState::Missing) => {
self.config.keep_awake.lid = wanted;
self.config_dirty = true;
None
}
(install, _) => Some(install),
};
// The click flipped the item already. Until a prompt's command ran it shows
// what is true.
if let Some(items) = &self.menu.keep_awake {
items.show_helper(state, self.config.keep_awake.lid);
}
match prompt {
Some(install) => self.run_lid_helper(install),
None => self.sync_keep_awake(),
}
}
fn run_lid_helper(&mut self, install: bool) {
if self.lid_helper_busy {
return;
}
self.lid_helper_busy = true;
let proxy = self.proxy.clone();
std::thread::spawn(move || {
let outcome = match install {
true => keep_awake::install_helper(),
false => keep_awake::remove_helper(),
};
let _ = proxy.send_event(UserEvent::LidHelperDone { install, outcome });
});
}
fn on_lid_helper_done(&mut self, install: bool, outcome: keep_awake::AdminOutcome) {
use keep_awake::{AdminOutcome, HelperState};
self.lid_helper_busy = false;
let state =
keep_awake::request_path().map_or(HelperState::Missing, |request| keep_awake::helper_state(&request));
let failure = match (&outcome, install, state) {
(AdminOutcome::Cancelled, _, _) => None,
(AdminOutcome::Failed(error), true, _) => Some(("The lid helper could not be installed.", error.clone())),
(AdminOutcome::Failed(error), false, _) => Some(("The lid helper could not be removed.", error.clone())),
(AdminOutcome::Done, true, HelperState::Installed) => {
self.config.keep_awake.lid = true;
self.config_dirty = true;
None
}
(AdminOutcome::Done, true, _) => Some((
"The lid helper could not be installed.",
"The installation ran, but the helper's files are not what the app wrote.".to_owned(),
)),
(AdminOutcome::Done, false, HelperState::Missing) => {
self.config.keep_awake.lid = false;
self.config_dirty = true;
None
}
(AdminOutcome::Done, false, _) => Some((
"The lid helper could not be removed.",
"The removal ran, but the helper's files are still there.".to_owned(),
)),
};
if let Some(items) = &self.menu.keep_awake {
items.show_helper(state, self.config.keep_awake.lid);
}
self.keep_awake.refresh();
self.sync_keep_awake();
if let Some((title, detail)) = failure {
eprintln!("codeman-desktop: {title} {detail}");
#[cfg(target_os = "macos")]
macos::show_message(title, &detail);
}
}
fn on_menu(&mut self, id: &str) {
@@ -1066,10 +1319,17 @@ impl App {
self.choose_logo_blink(signal, style);
return;
}
if let Some(mode) = keep_awake::Mode::from_menu_id(id) {
self.choose_keep_awake_mode(mode);
return;
}
match id {
keep_awake::AC_ONLY_ID => self.toggle_keep_awake_ac_only(),
keep_awake::LID_ID => self.toggle_keep_awake_lid(),
menu_id::SWITCH => self.show_connect_page(None, true),
menu_id::ADD_AGENT => self.add_agent(),
menu_id::RELOAD => self.reload_focused(),
menu_id::LOGO_BLINK => self.toggle_logo_blink(),
menu_id::START_FULLSCREEN => {
let value = self.menu.start_fullscreen.is_checked();
self.config.start_fullscreen = value;
+109 -4
View File
@@ -4,6 +4,7 @@ use muda::accelerator::{Accelerator, Code, Modifiers};
use muda::{AboutMetadata, CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
use crate::config::Config;
use crate::keep_awake::{self, HelperState, Mode};
use crate::logo::{self, BlinkStyle, Signal};
use crate::{APP_NAME, VERSION};
@@ -16,6 +17,7 @@ pub mod menu_id {
pub const ADD_AGENT: &str = "add-agent";
pub const RELOAD: &str = "reload";
pub const START_FULLSCREEN: &str = "start-fullscreen";
pub const LOGO_BLINK: &str = "logo-blink";
pub const DEVTOOLS: &str = "devtools";
pub const HELP_DOCS: &str = "help-docs";
pub const HELP_GITHUB: &str = "help-github";
@@ -26,9 +28,85 @@ pub struct AppMenu {
pub window_menu: Submenu,
pub help_menu: Submenu,
pub start_fullscreen: CheckMenuItem,
/// One item per state and style. Empty on a Mac without a lit logo, which has no
/// The one switch for the logo. None on a Mac without a lit logo, which has no
/// such menu.
pub logo_switch: Option<CheckMenuItem>,
/// One item per state and style, empty without the switch.
pub logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>,
/// What the closed lid did with the logo last, a line that cannot be
/// clicked. None without the switch.
pub logo_lid: Option<MenuItem>,
/// None where the app cannot keep the Mac awake (a build for another OS).
pub keep_awake: Option<KeepAwakeMenu>,
}
/// App menu > Keep This Mac Awake: Off / While Agents Work / Always, then on a
/// MacBook Only When Plugged In and With the Lid Closed…, then one status line.
pub struct KeepAwakeMenu {
pub modes: Vec<(Mode, CheckMenuItem)>,
/// Only on a Mac with a lid: a desktop Mac always runs on mains and cannot close.
pub ac_only: Option<CheckMenuItem>,
/// Ticked = the helper is in and asked for. Ticking or unticking it asks for the
/// administrator password (install, remove), hence the ellipsis.
pub lid: Option<CheckMenuItem>,
/// What it does right now (keep_awake::status_line). Not clickable.
pub status: MenuItem,
}
impl KeepAwakeMenu {
/// The lid item as the helper now is: ticked only while it is installed and on.
pub fn show_helper(&self, state: HelperState, lid_on: bool) {
if let Some(item) = &self.lid {
item.set_checked(lid_on && state == HelperState::Installed);
}
}
}
fn build_keep_awake(config: &Config) -> muda::Result<(Submenu, KeepAwakeMenu)> {
let settings = config.keep_awake;
let submenu = Submenu::new("Keep This Mac Awake", true);
let mut modes = Vec::new();
for mode in Mode::ALL {
let item = CheckMenuItem::with_id(mode.menu_id(), mode.label(), true, settings.mode == mode, None);
submenu.append(&item)?;
modes.push((mode, item));
}
let (mut ac_only, mut lid) = (None, None);
if keep_awake::has_lid() {
let state =
keep_awake::request_path().map_or(HelperState::Missing, |request| keep_awake::helper_state(&request));
let ac = CheckMenuItem::with_id(
keep_awake::AC_ONLY_ID,
"Only When Plugged In",
true,
settings.ac_only,
None,
);
let lid_item = CheckMenuItem::with_id(
keep_awake::LID_ID,
"With the Lid Closed\u{2026}",
true,
settings.lid && state == HelperState::Installed,
None,
);
submenu.append_items(&[&PredefinedMenuItem::separator(), &ac, &lid_item])?;
(ac_only, lid) = (Some(ac), Some(lid_item));
}
let initial = match settings.mode {
Mode::Off => keep_awake::OFF_TEXT,
_ => keep_awake::NO_SERVER_TEXT,
};
let status = MenuItem::new(initial, false, None);
submenu.append_items(&[&PredefinedMenuItem::separator(), &status])?;
Ok((
submenu,
KeepAwakeMenu {
modes,
ac_only,
lid,
status,
},
))
}
pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
@@ -67,6 +145,15 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
&PredefinedMenuItem::quit(None),
],
)?;
// Right under "Add a Coding Agent to This Mac…".
let keep_awake = match keep_awake::available() {
true => {
let (submenu, items) = build_keep_awake(config)?;
app_menu.insert(&submenu, 4)?;
Some(items)
}
false => None,
};
// Without these items Cmd+C / Cmd+V do nothing inside a WKWebView.
let edit = Submenu::with_items(
"Edit",
@@ -110,10 +197,20 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
),
],
)?;
// Only a Mac with a lit Apple logo has this menu at all.
// Only a Mac with a lit Apple logo has these items at all: the switch, and the
// style for each state behind it.
let mut logo_styles = Vec::new();
let mut logo_switch = None;
let mut logo_lid = None;
if logo::available() {
let submenu = Submenu::new("Blink Apple Logo", true);
let switch = CheckMenuItem::with_id(
menu_id::LOGO_BLINK,
"Blink Apple Logo",
true,
config.logo_blink.enabled,
None,
);
let submenu = Submenu::new("Apple Logo Blink Styles", true);
for signal in Signal::ALL {
let group = Submenu::new(signal.label(), true);
for style in BlinkStyle::ALL {
@@ -128,7 +225,12 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
submenu.append(&group)?;
}
// Right under "Always Open in Full Screen", before the separator.
view.insert(&submenu, 4)?;
let lid = MenuItem::new(logo::lid_status_line(), false, None);
view.insert(&switch, 4)?;
view.insert(&submenu, 5)?;
view.insert(&lid, 6)?;
logo_switch = Some(switch);
logo_lid = Some(lid);
}
let window_menu = Submenu::with_items(
"Window",
@@ -156,6 +258,9 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
window_menu,
help_menu,
start_fullscreen,
logo_switch,
logo_styles,
keep_awake,
logo_lid,
})
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codeman",
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
"version": "1.50.0",
"version": "1.50.1",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+1 -1
View File
@@ -226,7 +226,7 @@ const VOLATILE_KEYS: Readonly<Record<string, readonly string[]>> = {
// per user), a session started in another case, every captured prompt, and a Docker case's
// conversation id after a /clear, and a web tab being opened.
'users.json': ['lastLoginAt'],
'settings.json': ['lastUsedCase'],
'settings.json': ['lastUsedCase', 'lastPickedCase'],
'intents.json': ['recentPrompts', 'updatedAt'],
'docker-cases.json': ['lastClaudeSessionId'],
'webviews.json': ['lastOpenedAt'],
+6
View File
@@ -115,6 +115,12 @@ export interface FileWriteData {
eol: 'lf' | 'crlf';
}
/** Response payload for `DELETE /api/sessions/:id/files?path=` (the Files panel's delete button). */
export interface FileDeleteData {
/** The path as submitted (workspace-relative, or absolute inside the session's outside worktree) */
path: string;
}
export type CleanupResourceType = 'timer' | 'interval' | 'watcher' | 'listener' | 'stream';
/**
+48
View File
@@ -0,0 +1,48 @@
/**
* @fileoverview Pure helpers for the opt-in shared per-case run options (`caseSettings` in
* settings.json, written while `syncCaseState` is on). They run inside `mutateSettingsFile()`, so
* every change is serialised with the file's other writers: two devices saving different cases
* both land, where a replacement map would let the second discard the first.
*/
export interface CaseRunOptions {
agentTeams: boolean;
opusContext1m: boolean;
}
/** Most cases the shared map holds, counted after a merge (the schema bounds one request, not the file). */
export const MAX_SHARED_CASES = 500;
export class SharedCaseLimitError extends Error {
constructor() {
super(`Shared case options are limited to ${MAX_SHARED_CASES} cases`);
this.name = 'SharedCaseLimitError';
}
}
function asMap(value: unknown): Record<string, CaseRunOptions> {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, CaseRunOptions>) : {};
}
/** The stored map with `incoming` merged in per case. Throws SharedCaseLimitError past the limit. */
export function mergeCaseSettings(current: unknown, incoming: Record<string, CaseRunOptions>) {
const merged = { ...asMap(current), ...incoming };
if (Object.keys(merged).length > MAX_SHARED_CASES) throw new SharedCaseLimitError();
return merged;
}
/** The map with `from` moved to `to`, or null when there is nothing to move. */
export function renameCaseSettings(current: unknown, from: string, to: string) {
const map = asMap(current);
if (!Object.hasOwn(map, from)) return null;
const { [from]: moved, ...rest } = map;
return { ...rest, [to]: moved };
}
/** The map without `name`, or null when it has no entry for it. */
export function dropCaseSettings(current: unknown, name: string) {
const map = asMap(current);
if (!Object.hasOwn(map, name)) return null;
const { [name]: _gone, ...rest } = map;
return rest;
}
+5
View File
@@ -1232,6 +1232,11 @@ class CodemanApp {
// and then applies the sidebar variant on top — do not call both.
this.applySessionListLayout();
this.applyTabOrientation();
// The home screens' breakpoint listeners, once, before any home surface is
// shown: a fold or unfold must swap the surface whichever width the page
// loaded at (#702). Both self-gate on a home screen being up.
this._wireMobileOverviewBreakpoint?.();
this._wireHomeSessionsBreakpoint?.();
this.initTabRailResize?.();
this.applyMonitorVisibility();
this.applyLineageLineSettings?.();
+38 -18
View File
@@ -126,20 +126,31 @@ Object.assign(CodemanApp.prototype, {
this._stopHomeSessionsClock();
},
/** Re-render only when showing (called from the tab renderer's tail). */
/**
* Re-render only when showing (called from the tab renderer's tail). Asked
* again when the debounce fires: `renderHomeSessions()` unhides the column,
* so a render queued just before a fold to phone width hid it (#702) would
* otherwise bring it back behind the phone overview.
*/
_refreshHomeSessionsIfVisible() {
if (!this.isHomeSessionsVisible()) return;
this._debouncedCall('homeSessions', () => this.renderHomeSessions(), 150);
this._debouncedCall(
'homeSessions',
() => {
if (this.isHomeSessionsVisible() && this.shouldShowHomeSessions()) this.renderHomeSessions();
},
150
);
},
/**
* One delegated click listener for every row, plus a width listener so
* resizing the window while on the home screen adds or drops the column
* instead of leaving it overlapping the content it was sized to clear.
* One delegated click listener for every row. The width listener lives in
* `_wireHomeSessionsBreakpoint()`, installed at startup.
*/
_wireHomeSessions(el) {
if (this._homeSessionsWired) return;
this._homeSessionsWired = true;
this._wireHomeSessionsBreakpoint();
el.addEventListener('click', (event) => {
const target = event.target?.closest?.('[data-hs-action]');
@@ -150,20 +161,29 @@ Object.assign(CodemanApp.prototype, {
void this.openWebview?.(target.dataset.hsWebview);
}
});
},
if (window.matchMedia) {
const mq = window.matchMedia(`(min-width: ${HOME_SESSIONS_MIN_WIDTH}px)`);
const onChange = () => {
// Only relevant while the welcome screen is up; entering a session
// re-decides through hideWelcome()/showWelcome() anyway.
if (this.activeSessionId) return;
const overlay = document.getElementById('welcomeOverlay');
if (!overlay || !overlay.classList.contains('visible')) return;
this.showHomeSessions();
};
if (mq.addEventListener) mq.addEventListener('change', onChange);
else if (mq.addListener) mq.addListener(onChange);
}
/**
* Add or drop the column when the window crosses its width gate, so it never
* overlaps the content it was sized to clear. Installed ONCE at startup
* (`init()` in app.js) like the phone overview's breakpoint listener, so a
* page that loads on a phone and is unfolded to desktop width gets it too
* (#702). Idempotent.
*/
_wireHomeSessionsBreakpoint() {
if (this._homeSessionsBreakpointWired || !window.matchMedia) return;
this._homeSessionsBreakpointWired = true;
const mq = window.matchMedia(`(min-width: ${HOME_SESSIONS_MIN_WIDTH}px)`);
const onChange = () => {
// Only relevant while the welcome screen is up; entering a session
// re-decides through hideWelcome()/showWelcome() anyway.
if (this.activeSessionId) return;
const overlay = document.getElementById('welcomeOverlay');
if (!overlay || !overlay.classList.contains('visible')) return;
this.showHomeSessions();
};
if (mq.addEventListener) mq.addEventListener('change', onChange);
else if (mq.addListener) mq.addListener(onChange);
},
// ═══════════════════════════════════════════════════════════════
+10
View File
@@ -1688,6 +1688,11 @@
// The file name is user text: it passes through untranslated.
[/^Downloaded (.+) to Downloads$/, (_m, name) => `已将 ${name} 下载到“下载”文件夹`],
[/^Download of (.+) failed$/, (_m, name) => `下载 ${name} 失败`],
// The Files panel's delete button (panels-ui.js): the path is user text, and the server's
// reason passes through untranslated.
[/^Delete the file ([\s\S]+)\? This cannot be undone\.$/, (_m, path) => `删除文件 ${path}?此操作无法撤销。`],
[/^File deleted: ([\s\S]+)$/, (_m, path) => `已删除文件:${path}`],
[/^Delete failed: ([\s\S]+)$/, (_m, why) => `删除失败:${why}`],
// Group names are user text: they pass through untranslated.
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
// Session names are user text too.
@@ -1734,6 +1739,11 @@
(_m, what, why) =>
`${what === 'Run mode' ? '运行模式' : '重生预设'}已保存到本设备,但未保存到服务器:${ZH_CN[why] || why}`,
],
// The case-options save (session-ui.js _pushCaseSettings): the case name passes through.
[
/^Options for "([^"]+)" \(until the next sync\) saved on this device, not on the server: ([\s\S]+)$/,
(_m, name, why) => `案例“${name}”的选项已保存到本设备(同步前有效),但未保存到服务器:${ZH_CN[why] || why}`,
],
// A dialog read off a hook-less CLI's screen (src/web/screen-blocked.ts), as the
// notification drawer shows it. The CLI's label passes through.
[/^(.+) is waiting for your answer in the terminal$/, (_m, cli) => `${cli} 正在终端中等待你的回答`],
+21
View File
@@ -3456,6 +3456,20 @@
<option value="compose">Compose dialog</option>
</select>
</div>
<div class="set-row has-field" data-search="voice silence pause timeout manual stop recording">
<div class="set-row-text">
<label class="set-row-label" for="voiceSilenceTimeout">Stop after silence</label>
<span class="set-row-desc">Allow time to think between phrases. Manual keeps listening until you tap the mic again.</span>
</div>
<select id="voiceSilenceTimeout" class="set-select">
<option value="default">Provider default</option>
<option value="3">3 seconds</option>
<option value="6">6 seconds</option>
<option value="15">15 seconds</option>
<option value="30">30 seconds</option>
<option value="0">Manual</option>
</select>
</div>
<div class="set-row has-field" data-search="voice language">
<div class="set-row-text">
<span class="set-row-label">Language</span>
@@ -3572,6 +3586,13 @@
</div>
<input type="text" id="appSettingsDefaultDir" class="set-input" placeholder="~/projects">
</div>
<div class="set-row" data-search="sync cases across devices selected case agent teams opus 1m">
<div class="set-row-text">
<span class="set-row-label">Sync cases across devices</span>
<span class="set-row-desc">Keeps each case's Agent Teams and Opus 1M options on the server and switches this device to the case you last chose on another one when you return to the tab. Off by default: each browser keeps its own. The case names are stored in the shared settings file.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsSyncCaseState" aria-label="Sync cases across devices"><span class="slider"></span></label>
</div>
</div>
</div>
+160 -2
View File
@@ -74,6 +74,8 @@ const PathPicker = {
_previewOverlay: null,
_previewRequestSequence: 0,
_previewPreviousFocus: null,
_previewImageCleanup: null,
_previewImageZoom: null,
_showHidden: false,
_sortMode: PATH_PICKER_DEFAULT_SORT,
@@ -563,6 +565,11 @@ const PathPicker = {
<strong class="path-preview-title"></strong>
<span class="path-preview-path"></span>
</div>
<div class="path-preview-zoom" hidden aria-label="Image zoom controls">
<button type="button" data-zoom="out" aria-label="Zoom out">−</button>
<button type="button" data-zoom="fit" aria-label="Fit image">Fit</button>
<button type="button" data-zoom="in" aria-label="Zoom in">+</button>
</div>
<a class="path-preview-open" target="_blank" rel="noopener noreferrer">Open</a>
<button type="button" class="path-preview-close" aria-label="Close preview">&times;</button>
</div>
@@ -580,13 +587,30 @@ const PathPicker = {
const body = overlay.querySelector('.path-preview-body');
if (entry.previewKind === 'image') {
const zoomControls = overlay.querySelector('.path-preview-zoom');
body.classList.add('path-preview-image-viewport');
const image = document.createElement('img');
image.className = 'path-preview-image';
image.alt = entry.name;
image.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove());
image.addEventListener('error', () => this.showPreviewError('Image preview failed to load'));
image.draggable = false;
// Both handlers check the sequence like the text branch: an image whose preview was already
// replaced can still finish loading, and `this._previewOverlay` and the zoom state are then
// the newer preview's. The controls show only once there is an image to zoom.
image.addEventListener('load', () => {
if (requestSequence !== this._previewRequestSequence) return;
body.querySelector('.path-preview-loading')?.remove();
zoomControls.hidden = false;
this._setPreviewImageZoom(1);
});
image.addEventListener('error', () => {
if (requestSequence !== this._previewRequestSequence) return;
zoomControls.hidden = true;
this._previewImageCleanup?.();
this.showPreviewError('Image preview failed to load');
});
image.src = previewUrl;
body.appendChild(image);
this._bindPreviewImageGestures(image, body, zoomControls);
} else if (entry.previewKind === 'text') {
fetch(previewUrl)
.then(async (response) => {
@@ -630,8 +654,142 @@ const PathPicker = {
body.replaceChildren(error);
},
_setPreviewImageZoom(scale, focalPoint) {
const state = this._previewImageZoom;
if (!state) return;
const nextScale = Math.max(1, Math.min(6, scale));
if (focalPoint) {
const rect = state.body.getBoundingClientRect();
const qx = focalPoint.x - (rect.left + rect.width / 2);
const qy = focalPoint.y - (rect.top + rect.height / 2);
const ratio = nextScale / state.scale;
state.x = qx + (state.x - qx) * ratio;
state.y = qy + (state.y - qy) * ratio;
}
state.scale = nextScale;
this._clampPreviewImagePan();
state.image.style.transform = `translate(${state.x}px, ${state.y}px) scale(${state.scale})`;
const fitButton = state.controls.querySelector('[data-zoom="fit"]');
if (fitButton) fitButton.textContent = `${Math.round(state.scale * 100)}%`;
state.image.classList.toggle('is-zoomed', state.scale > 1);
},
/** Size the image is drawn at before zoom: `object-fit: scale-down` in the viewport box, so never
* larger than its natural size. The box until the image has loaded (no natural size yet). */
_previewImageFitSize(state, rect) {
const naturalWidth = state.image.naturalWidth;
const naturalHeight = state.image.naturalHeight;
if (!naturalWidth || !naturalHeight) return { width: rect.width, height: rect.height };
const fit = Math.min(1, rect.width / naturalWidth, rect.height / naturalHeight);
return { width: naturalWidth * fit, height: naturalHeight * fit };
},
/** Keep the zoomed image covering the viewport: it may move until its edge reaches the viewport's
* edge, never further, and an axis on which it is still smaller than the viewport stays centred.
* Clamping to the image box instead let a small or letterboxed image be panned out of view. */
_clampPreviewImagePan() {
const state = this._previewImageZoom;
if (!state) return;
const rect = state.body.getBoundingClientRect();
const fitted = this._previewImageFitSize(state, rect);
const maxX = Math.max(0, (fitted.width * state.scale - rect.width) / 2);
const maxY = Math.max(0, (fitted.height * state.scale - rect.height) / 2);
state.x = Math.max(-maxX, Math.min(maxX, state.x));
state.y = Math.max(-maxY, Math.min(maxY, state.y));
},
/** Zoom factor for one wheel event, proportional to `deltaY` (d3-zoom's scale: about 15% per
* 100px mouse notch, ten times that for ctrl+wheel, which is how Chrome and Firefox deliver a
* trackpad pinch), so a trackpad's stream of small deltas zooms smoothly instead of 20% per
* event. Clamped per event so one large delta cannot jump to the maximum; no vertical delta
* (a sideways scroll) zooms nothing. */
_previewWheelZoomFactor(event) {
const deltaY = Number(event?.deltaY) || 0;
if (!deltaY) return 1;
const perUnit = event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002;
const exponent = -deltaY * perUnit * (event.ctrlKey ? 10 : 1);
return 2 ** Math.max(-0.5, Math.min(0.5, exponent));
},
_bindPreviewImageGestures(image, body, controls) {
this._previewImageZoom = { image, body, controls, scale: 1, x: 0, y: 0, pointers: new Map(), gesture: null };
const state = this._previewImageZoom;
const cleanups = [];
const listen = (target, name, handler, options) => {
target.addEventListener(name, handler, options);
cleanups.push(() => target.removeEventListener(name, handler, options));
};
const pointer = (event) => ({ x: event.clientX, y: event.clientY });
const midpoint = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
const startGesture = () => {
const points = Array.from(state.pointers.values());
if (points.length > 1) {
state.gesture = {
kind: 'pinch',
scale: state.scale,
distance: Math.max(1, distance(points[0], points[1])),
midpoint: midpoint(points[0], points[1]),
};
} else if (points.length === 1) {
state.gesture = { kind: 'pan', point: points[0], x: state.x, y: state.y };
}
};
listen(image, 'pointerdown', (event) => {
event.preventDefault();
image.setPointerCapture?.(event.pointerId);
state.pointers.set(event.pointerId, pointer(event));
startGesture();
});
listen(image, 'pointermove', (event) => {
if (!state.pointers.has(event.pointerId) || !state.gesture) return;
event.preventDefault();
state.pointers.set(event.pointerId, pointer(event));
if (state.pointers.size > 1) {
const points = Array.from(state.pointers.values());
this._setPreviewImageZoom(
state.gesture.scale * distance(points[0], points[1]) / state.gesture.distance,
state.gesture.midpoint
);
} else if (state.gesture.kind === 'pan' && state.scale > 1) {
const point = state.pointers.values().next().value;
state.x = state.gesture.x + point.x - state.gesture.point.x;
state.y = state.gesture.y + point.y - state.gesture.point.y;
this._setPreviewImageZoom(state.scale);
}
});
const endPointer = (event) => {
state.pointers.delete(event.pointerId);
startGesture();
};
listen(image, 'pointerup', endPointer);
listen(image, 'pointercancel', endPointer);
listen(image, 'lostpointercapture', endPointer);
listen(image, 'dblclick', (event) => {
event.preventDefault();
this._setPreviewImageZoom(state.scale > 1 ? 1 : 2, pointer(event));
});
listen(body, 'wheel', (event) => {
event.preventDefault();
const factor = this._previewWheelZoomFactor(event);
if (factor !== 1) this._setPreviewImageZoom(state.scale * factor, pointer(event));
}, { passive: false });
listen(controls, 'click', (event) => {
const button = event.target.closest('[data-zoom]');
if (!button) return;
if (button.dataset.zoom === 'fit') this._setPreviewImageZoom(1);
else this._setPreviewImageZoom(state.scale * (button.dataset.zoom === 'in' ? 1.5 : 1 / 1.5));
});
this._previewImageCleanup = () => {
cleanups.forEach((cleanup) => cleanup());
this._previewImageZoom = null;
this._previewImageCleanup = null;
};
},
closePreview(restoreFocus = true) {
this._previewRequestSequence += 1;
this._previewImageCleanup?.();
this._previewOverlay?.remove();
this._previewOverlay = null;
const previousFocus = this._previewPreviousFocus;
+30 -14
View File
@@ -385,13 +385,13 @@ Object.assign(CodemanApp.prototype, {
},
/**
* One delegated click listener for every row, plus a breakpoint listener so
* rotating or unfolding while on the home screen swaps to the right surface
* instead of stranding a phone layout on a tablet-width viewport.
* One delegated click listener for every row. The breakpoint listener lives
* in `_wireMobileOverviewBreakpoint()`, installed at startup.
*/
_wireMobileOverview(el) {
if (this._mobileOverviewWired) return;
this._mobileOverviewWired = true;
this._wireMobileOverviewBreakpoint();
el.addEventListener('click', (event) => {
const target = event.target && event.target.closest && event.target.closest('[data-mo-action]');
@@ -426,18 +426,34 @@ Object.assign(CodemanApp.prototype, {
this.showWebviewModal();
}
});
},
if (window.matchMedia) {
const mq = window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY);
const onChange = () => {
// Only relevant while a home surface is up; entering a session re-decides
// through hideWelcome()/showWelcome() anyway.
if (this.activeSessionId) return;
if (typeof this.showWelcome === 'function') this.showWelcome();
};
if (mq.addEventListener) mq.addEventListener('change', onChange);
else if (mq.addListener) mq.addListener(onChange);
}
/**
* Swap the home surface when the width crosses the phone breakpoint (rotate,
* fold, unfold), so a phone layout is never stranded on a tablet-width
* viewport and the welcome screen never on a phone-width one.
*
* Installed ONCE at startup (`init()` in app.js), not on the first show: a
* page that loads wide never shows the phone overview, so a listener wired
* from there was missing on exactly the fold that needed it (#702). Idempotent,
* so the call from `_wireMobileOverview()` adds nothing after startup.
*/
_wireMobileOverviewBreakpoint() {
if (this._mobileOverviewBreakpointWired || !window.matchMedia) return;
this._mobileOverviewBreakpointWired = true;
const mq = window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY);
const onChange = () => {
// Only relevant while a home surface is up; entering a session re-decides
// through hideWelcome()/showWelcome() anyway, and before the first
// showWelcome() there is nothing on screen to swap.
if (this.activeSessionId) return;
const overlay = document.getElementById('welcomeOverlay');
const welcomeUp = !!overlay && overlay.classList.contains('visible');
if (!welcomeUp && !this.isMobileOverviewVisible()) return;
if (typeof this.showWelcome === 'function') this.showWelcome();
};
if (mq.addEventListener) mq.addEventListener('change', onChange);
else if (mq.addListener) mq.addListener(onChange);
},
_toggleMobileOverviewRunMenu() {
+61
View File
@@ -3777,6 +3777,7 @@ Object.assign(CodemanApp.prototype, {
}
const html = [];
const canDelete = this._fileBrowserCanDelete(owner);
const renderNode = (node, depth) => {
const isDir = node.type === 'directory';
@@ -3799,6 +3800,13 @@ Object.assign(CodemanApp.prototype, {
const downloadBtn = !isDir
? `<a class="file-tree-download" href="${escapeHtml(CodemanBase.url(`/api/sessions/${encodeURIComponent(owner)}/file-raw?path=${encodeURIComponent(node.path)}&download=true`))}" title="Download" onclick="event.stopPropagation()">&#x2B07;</a>`
: '';
// No inline handler: the path is a workspace file name, which an agent or a cloned repo
// chose, and `encodeURIComponent` leaves `'` alone, so a quoted string in an onclick ran
// whatever a file name like `x'-alert(1)-'.txt` held (#529's review). The click is bound
// below from the row's already-escaped `data-path`.
const deleteBtn = !isDir && canDelete
? `<button type="button" class="file-tree-delete" title="Delete" aria-label="Delete ${escapeHtml(node.name)}">&times;</button>`
: '';
html.push(`
<div class="file-tree-item" data-path="${escapeHtml(node.path)}" data-type="${escapeHtml(node.type)}" data-depth="${depth}">
@@ -3807,6 +3815,7 @@ Object.assign(CodemanApp.prototype, {
<span class="${nameClass}">${escapeHtml(node.name)}</span>
${sizeStr}
${downloadBtn}
${deleteBtn}
</div>
`);
@@ -3842,9 +3851,21 @@ Object.assign(CodemanApp.prototype, {
this.openFilePreview(path, owner);
}
});
item.querySelector('.file-tree-delete')?.addEventListener('click', (e) => {
e.stopPropagation();
this.deleteFileFromBrowser(owner, item.dataset.path);
});
});
},
// The delete button is offered only where the route can act: never for a remote (SSH) session,
// whose files live on another host, nor for an adopted one, whose folder is not a workspace of
// ours (`session.remote` is undefined on an adopted session, so both are tested).
_fileBrowserCanDelete(sessionId) {
const session = this.sessions?.get?.(sessionId);
return !session?.remote && !session?.adopt;
},
toggleFileBrowserFolder(path) {
if (this.fileBrowserExpandedDirs.has(path)) {
this.fileBrowserExpandedDirs.delete(path);
@@ -3970,6 +3991,7 @@ Object.assign(CodemanApp.prototype, {
treeEl.innerHTML = `<div class="file-browser-empty">${escapeHtml('No matches')}</div>`;
} else {
const ownerPath = encodeURIComponent(ownerSessionId);
const canDelete = this._fileBrowserCanDelete(ownerSessionId);
treeEl.innerHTML = matches
.map(match => {
const isDir = match.type === 'directory';
@@ -3981,6 +4003,10 @@ Object.assign(CodemanApp.prototype, {
const downloadBtn = !isDir
? `<a class="file-tree-download" href="${escapeHtml(CodemanBase.url(`/api/sessions/${ownerPath}/file-raw?path=${encodeURIComponent(match.path)}&download=true`))}" title="Download" onclick="event.stopPropagation()">&#x2B07;</a>`
: '';
// Bound below from `data-path`, never an inline handler (see renderFileBrowserTree).
const deleteBtn = !isDir && canDelete
? `<button type="button" class="file-tree-delete" title="Delete" aria-label="Delete ${escapeHtml(match.name)}">&times;</button>`
: '';
return `
<div class="file-tree-item" data-path="${escapeHtml(match.path)}" data-type="${escapeHtml(match.type)}" data-owner="${escapeHtml(ownerSessionId)}">
<span class="file-tree-expand"></span>
@@ -3988,6 +4014,7 @@ Object.assign(CodemanApp.prototype, {
<span class="${nameClass}">${escapeHtml(match.name)}</span>
${sizeStr}
${downloadBtn}
${deleteBtn}
</div>
`;
})
@@ -4003,6 +4030,10 @@ Object.assign(CodemanApp.prototype, {
this.openFilePreview(path, ownerSessionId);
}
});
item.querySelector('.file-tree-delete')?.addEventListener('click', (e) => {
e.stopPropagation();
this.deleteFileFromBrowser(ownerSessionId, item.dataset.path);
});
});
const statusEl = this.$('fileBrowserStatus');
@@ -4012,6 +4043,33 @@ Object.assign(CodemanApp.prototype, {
}
},
// The Files panel's delete button (DELETE .../files?path=): asks first, in the same native
// dialog the panel's other destructive actions use, then reloads the listing. With a search in
// the box the reload re-runs it, so the deleted row leaves the results too; a plain reload
// would refresh the tree behind them and leave the row on screen.
async deleteFileFromBrowser(sessionId, filePath) {
if (!sessionId || !filePath) return;
if (!confirm(`Delete the file ${filePath}? This cannot be undone.`)) return;
let result = null;
try {
const response = await fetch(
`/api/sessions/${encodeURIComponent(sessionId)}/files?path=${encodeURIComponent(filePath)}`,
{ method: 'DELETE' },
);
result = await response.json().catch(() => null);
if (!response.ok || result?.success !== true) {
throw new Error(result?.error || `HTTP ${response.status}`);
}
} catch (error) {
console.error('File browser delete failed:', error);
this.showToast(`Delete failed: ${error?.message || 'network error'}`, 'error');
return;
}
this.showToast(`File deleted: ${filePath}`, 'success');
const state = this._ensureFileBrowserState();
if (state.ownerSessionId === sessionId) await this._reloadFileBrowserKeepingQuery();
},
_findFileBrowserDirectory(nodes, targetPath, ancestors = []) {
if (!Array.isArray(nodes)) return null;
for (const node of nodes) {
@@ -6105,6 +6163,7 @@ Object.assign(CodemanApp.prototype, {
CodemanBase.url(`/api/sessions/${sessionId}/tail-file?path=${encodeURIComponent(filePath)}&lines=50`)
);
let connected = false;
eventSource.onmessage = (e) => {
const data = JSON.parse(e.data);
const body = document.getElementById(`log-viewer-body-${windowId}`);
@@ -6112,6 +6171,7 @@ Object.assign(CodemanApp.prototype, {
switch (data.type) {
case 'connected':
connected = true;
body.innerHTML = '';
break;
case 'data':
@@ -6131,6 +6191,7 @@ Object.assign(CodemanApp.prototype, {
this.updateLogViewerStatus(windowId, 'disconnected', 'ended');
break;
case 'error':
if (!connected) eventSource.close();
body.innerHTML += `<div class="log-error">${escapeHtml(data.error)}</div>`;
this.updateLogViewerStatus(windowId, 'error', 'error');
break;
+131 -9
View File
@@ -613,6 +613,9 @@ Object.assign(CodemanApp.prototype, {
this.updateCasePickerInput(select.value);
});
select.dataset.listenerAdded = 'true';
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') this._syncCaseStateFromServer();
});
}
this.setupQuickStartCasePicker();
// The phone overview labels rows with their case name, and a case rename or
@@ -640,15 +643,29 @@ Object.assign(CodemanApp.prototype, {
// Multi-user non-admin: lastUsedCase is a key of the server-wide settings.json, whose
// PUT is admin-only (403), so it is not sent (_settingsReadOnly, settings-ui.js).
if (this._settingsReadOnly?.()) return;
try {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lastUsedCase: caseName })
});
} catch (err) {
console.error('Failed to save last used case:', err);
}
// lastPickedCase is only what a person chose on a device (never a quick-start or an agent's spawn),
// so it is what other devices follow when case sync is on.
const sync = this._caseStateSyncOn?.() === true;
const body = sync ? { lastUsedCase: caseName, lastPickedCase: caseName } : { lastUsedCase: caseName };
const send = async () => {
let stored = false;
try {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
stored = res?.ok !== false;
} catch (err) {
console.error('Failed to save last used case:', err);
}
// While this device's own pick is not on the server, the next tab return must not follow the server's older one.
if (sync) this._ownPickNotStored = !stored;
};
if (!sync) return send();
// On the chain the tab-return refresh already awaits, so a pick lands before the copy it reads.
this._caseSyncChain = (this._caseSyncChain || Promise.resolve()).then(send, send);
return this._caseSyncChain;
},
async quickStart() {
@@ -3670,6 +3687,111 @@ Object.assign(CodemanApp.prototype, {
saveCaseSettings(caseName, settings) {
localStorage.setItem('caseSettings_' + caseName, JSON.stringify(settings));
if (this._caseStateSyncOn()) this._queueCaseSync(caseName, settings);
},
// ── Opt-in case sync (`syncCaseState`): case options live on the server, the selected case follows other devices ──
/** Off unless the user turned it on; a non-admin in multi-user mode cannot write the shared settings.json. */
_caseStateSyncOn() {
return this.loadAppSettingsFromStorage?.()?.syncCaseState === true && !this._settingsReadOnly?.();
},
/**
* Send one case's options. Saves from this device run one after another in the order they were made
* (one chain), and each carries only its own case: the server merges it into the shared map, so a save
* from another device is never overwritten. `_caseSyncPending` counts a case's saves still in flight,
* so an import of the server's copy leaves that case alone.
*/
_queueCaseSync(caseName, settings) {
const entry = { agentTeams: !!settings.agentTeams, opusContext1m: !!settings.opusContext1m };
const pending = this._caseSyncPending || (this._caseSyncPending = new Map());
pending.set(caseName, (pending.get(caseName) || 0) + 1);
const run = async () => {
try {
await this._pushCaseSettings(caseName, entry);
} finally {
const left = (pending.get(caseName) || 1) - 1;
if (left > 0) pending.set(caseName, left);
else pending.delete(caseName);
}
};
this._caseSyncChain = (this._caseSyncChain || Promise.resolve()).then(run, run);
return this._caseSyncChain;
},
async _pushCaseSettings(caseName, entry) {
let res = null;
try {
res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseSettings: { [caseName]: entry } }),
});
} catch (err) {
console.error('Failed to sync case settings:', err);
}
// A refusal or failure keeps the choice on this device only until the next sync brings the server's copy back.
await this._noteSettingsWriteResult?.(res, `Options for "${caseName}" (until the next sync)`);
},
/** Server copy wins, except for a case this device is still saving: write its options into the local keys getCaseSettings() reads. */
_applyServerCaseSettings(server) {
if (server?.syncCaseState !== true || !server.caseSettings || typeof server.caseSettings !== 'object') return;
// A non-admin never receives the map (the server redacts it) and must not apply one it was handed.
if (this._settingsReadOnly?.()) return;
try {
let changed = false;
for (const [name, s] of Object.entries(server.caseSettings)) {
if (!s || typeof s !== 'object' || this._caseSyncPending?.has(name)) continue;
const next = JSON.stringify({ agentTeams: s.agentTeams === true, opusContext1m: s.opusContext1m === true });
if (localStorage.getItem('caseSettings_' + name) === next) continue;
localStorage.setItem('caseSettings_' + name, next);
changed = true;
}
// An open popover still shows the options it was filled with: closing it keeps a tick from writing them into another case.
if (changed) this._closeCaseSettingsPopovers();
} catch { /* storage blocked: this device keeps what it has */ }
},
_closeCaseSettingsPopovers() {
document.getElementById('caseSettingsPopover')?.classList.add('hidden');
document.getElementById('caseSettingsPopoverMobile')?.classList.add('hidden');
},
/** The server decides whether sync is on: when it says otherwise, this device's cached switch follows it. */
_adoptServerCaseSyncPreference(server) {
if (typeof server?.syncCaseState !== 'boolean') return;
const cached = this.loadAppSettingsFromStorage();
if ((cached.syncCaseState === true) === server.syncCaseState) return;
this.saveAppSettingsToStorage?.({ ...cached, syncCaseState: server.syncCaseState });
},
/** On returning to the tab: take the other devices' case options and selected case, if the server has sync on. */
async _syncCaseStateFromServer() {
if (this._settingsReadOnly?.()) return;
// Off on this device: no request on every tab return. Turning it on elsewhere is learned from the
// next settings load; turning it off elsewhere still reaches a device whose cached switch is on.
if (this.loadAppSettingsFromStorage?.()?.syncCaseState !== true) return;
const seq = (this._caseSyncRefresh = (this._caseSyncRefresh || 0) + 1);
try {
const chain = this._caseSyncChain;
await chain; // this device's own saves land first, so the copy read next includes them
const res = await fetch('/api/settings');
if (!res.ok) return;
const server = (await res.json())?.data ?? null;
if (seq !== this._caseSyncRefresh || !server) return; // a newer refresh owns the answer
if (this._caseSyncChain !== chain) return; // a save or pick made while the read was out is newer than it
this._adoptServerCaseSyncPreference(server);
if (server.syncCaseState !== true) return;
this._applyServerCaseSettings(server);
const select = document.getElementById('quickStartCase');
const last = server.lastPickedCase;
if (select && last && !this._ownPickNotStored && last !== select.value && this.cases?.some((c) => c.name === last)) {
this._closeCaseSettingsPopovers();
this.selectQuickStartCase(last, { save: false });
}
} catch { /* offline: try again next time the tab is shown */ }
},
onCaseSettingChanged() {
+8 -1
View File
@@ -560,6 +560,7 @@ Object.assign(CodemanApp.prototype, {
// .checked fires no onchange, so the list's visibility (and lazy load)
// needs an explicit sync on every open, not just a save.
this.applyCliManagementVisibility();
document.getElementById('appSettingsSyncCaseState').checked = settings.syncCaseState === true;
// MCP server sync: synced, default OFF; same explicit-sync reasoning as above.
// The routes read the SAVED setting, so remember what it was on open: switching it on
// here does nothing server-side until Save (see mcpSync()).
@@ -810,6 +811,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('voiceKeyterms').value = voiceCfg.keyterms || DEFAULT_VOICE_KEYTERMS;
document.getElementById('voiceInsertMode').value = voiceCfg.insertMode || 'direct';
document.getElementById('voiceProvider').value = voiceCfg.provider || 'auto';
document.getElementById('voiceSilenceTimeout').value = VoiceInput.SILENCE_TIMEOUT_SECONDS.includes(voiceCfg.silenceTimeout)
? String(voiceCfg.silenceTimeout) : 'default';
document.getElementById('appSettingsClaudeVoice').checked = settings.claudeVoiceEnabled ?? false;
// Reset key visibility to hidden
const keyInput = document.getElementById('voiceDeepgramKey');
@@ -3139,6 +3142,7 @@ Object.assign(CodemanApp.prototype, {
customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked,
cliManagementEnabled: document.getElementById('appSettingsCliManagement').checked,
mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked,
syncCaseState: document.getElementById('appSettingsSyncCaseState').checked,
configBackupEnabled: document.getElementById('appSettingsConfigBackup').checked,
configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(),
...this._configBackupRetentionPayload(_prev),
@@ -3297,6 +3301,8 @@ Object.assign(CodemanApp.prototype, {
language: document.getElementById('voiceLanguage').value,
keyterms: document.getElementById('voiceKeyterms').value.trim(),
insertMode: document.getElementById('voiceInsertMode').value,
silenceTimeout: document.getElementById('voiceSilenceTimeout').value === 'default'
? undefined : Number(document.getElementById('voiceSilenceTimeout').value),
};
VoiceInput._saveDeepgramConfig(voiceSettings);
@@ -5243,7 +5249,7 @@ Object.assign(CodemanApp.prototype, {
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.success === true ? env.data : env);
if (settings) {
// Extract notification prefs before merging app settings
const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings;
const { notificationPreferences, voiceSettings, respawnPresets, runMode, caseSettings: _sharedCases, ...appSettings } = settings;
// Display settings are device-specific (mobile vs desktop) and must not be synced
// from the server: see _displaySettingKeySet().
const displayKeys = this._displaySettingKeySet();
@@ -5280,6 +5286,7 @@ Object.assign(CodemanApp.prototype, {
// installs that had saved `false` (agent-skill-default-migration.ts).
if (!('agentSkillEnabled' in appSettings)) delete merged.agentSkillEnabled;
this.saveAppSettingsToStorage(merged);
this._applyServerCaseSettings?.(settings);
// Apply notification prefs from server if present (only if localStorage has none)
if (notificationPreferences && this.notificationManager) {
+72 -4
View File
@@ -12386,6 +12386,32 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
.file-tree-download:hover {
color: #4fc3f7;
}
.file-tree-delete {
display: none;
margin-left: 2px;
padding: 0 4px;
border: 0;
background: transparent;
color: #888;
font-size: 14px;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
}
.file-tree-item:hover .file-tree-delete {
display: inline;
}
.file-tree-delete:hover {
color: #ef5350;
}
/* A touch screen has no hover, so the row's buttons stay visible there: otherwise the first tap
on a row opens the preview and the buttons never show. */
@media (hover: none) {
.file-tree-download,
.file-tree-delete {
display: inline;
}
}
/* File tree indent levels */
.file-tree-item[data-depth="0"] { padding-left: 0.5rem; }
@@ -16218,6 +16244,29 @@ body.touch-device.cjk-input-visible .main {
text-decoration: none;
}
.path-preview-zoom {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 3px;
}
.path-preview-zoom[hidden] {
display: none;
}
.path-preview-zoom button {
min-width: 32px;
height: 34px;
padding: 0 6px;
color: var(--text);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 7px;
font-size: 0.75rem;
cursor: pointer;
}
.path-preview-close {
width: 38px;
height: 38px;
@@ -16236,6 +16285,12 @@ body.touch-device.cjk-input-visible .main {
background: #111;
}
.path-preview-body.path-preview-image-viewport {
overflow: hidden;
touch-action: none;
overscroll-behavior: contain;
}
.path-preview-loading,
.path-preview-error {
padding: 24px;
@@ -16250,10 +16305,23 @@ body.touch-device.cjk-input-visible .main {
.path-preview-image {
display: block;
max-width: 100%;
max-height: 100%;
margin: auto;
object-fit: contain;
width: 100%;
height: 100%;
/* scale-down, not contain: a small image keeps its own size instead of being blown up to the
viewport (the zoom controls are there to enlarge it). */
object-fit: scale-down;
touch-action: none;
user-select: none;
-webkit-user-drag: none;
cursor: zoom-in;
}
.path-preview-image.is-zoomed {
cursor: grab;
}
.path-preview-image.is-zoomed:active {
cursor: grabbing;
}
.path-preview-frame {
+232 -137
View File
@@ -14,11 +14,11 @@
* auto-detects MIME type (opus/webm/mp4), and supports custom key terms for dev vocabulary.
*
* - VoiceInput — High-level voice input controller. Toggle mode: tap mic to start, tap
* again to stop. Auto-stops after 3s silence. Shows floating preview overlay with recording
* again to stop. Auto-stops after configurable silence. Shows floating preview overlay with recording
* indicator, level meter (AnalyserNode), and elapsed timer. Two insert modes: "direct"
* (inject into local echo overlay or PTY) and "compose" (editable textarea overlay).
* Includes a temporary green Send button that replaces the settings gear icon after voice input.
* Web Speech API has auto-retry (up to 2x) for premature onend and iOS Safari stability check.
* Web Speech API retries empty runs twice with backoff and preserves late results after Stop.
*
* @globals {object} ClaudeVoiceProvider
* @globals {object} DeepgramProvider
@@ -216,9 +216,12 @@ const DeepgramProvider = {
_resetSilenceTimeout() {
clearTimeout(this._silenceTimeout);
this._silenceTimeout = null;
const delay = VoiceInput.getSilenceTimeoutMs(3000);
if (delay === 0) return;
this._silenceTimeout = setTimeout(() => {
this.stop();
}, 3000);
}, delay);
},
stop() {
@@ -269,8 +272,8 @@ const DeepgramProvider = {
* DeepgramProvider's recorder. The AudioContext is constructed at 16000 Hz so the
* browser does the resampling.
*
* ⚠️ Transcript frames carry the WHOLE running transcript, not deltas. Callers
* must replace, never concatenate.
* Transcript frames cover the current utterance. Replace interims and append
* each endpoint final once; an endpoint alone does not end the recording.
*/
const ClaudeVoiceProvider = {
_ws: null,
@@ -284,6 +287,8 @@ const ClaudeVoiceProvider = {
_onError: null,
_onEnd: null,
_finalized: false,
_finalizeTimeout: null,
_generation: 0,
/** How long without any transcript before the recording gives up on its own. */
SILENCE_MS: 6000,
@@ -293,6 +298,8 @@ const ClaudeVoiceProvider = {
* @param {object} opts - { language, keyterms[], onResult(text, isFinal), onError(msg), onEnd(), onStream(stream) }
*/
async start(opts) {
this._cleanup();
const generation = this._generation;
this._onResult = opts.onResult;
this._onError = opts.onError;
this._onEnd = opts.onEnd;
@@ -304,10 +311,17 @@ const ClaudeVoiceProvider = {
return;
}
try {
this._stream = await navigator.mediaDevices.getUserMedia({
const stream = await navigator.mediaDevices.getUserMedia({
audio: { noiseSuppression: true, echoCancellation: true, autoGainControl: true }
});
// Permission prompts can resolve after Stop/reconnect or a newer recording.
if (generation !== this._generation || this._finalized) {
stream.getTracks().forEach(t => t.stop());
return;
}
this._stream = stream;
} catch (err) {
if (generation !== this._generation || this._finalized) return;
const msg = err.name === 'NotAllowedError'
? 'Microphone access denied. Check browser settings.'
: 'Microphone error: ' + err.message;
@@ -331,10 +345,12 @@ const ClaudeVoiceProvider = {
this._ws.binaryType = 'arraybuffer';
this._ws.onopen = () => {
if (generation !== this._generation || this._finalized) return;
// Capture starts only once the socket is up: PCM buffered before that would
// be the oldest audio, and dropping it keeps the transcript aligned with what
// the user hears themselves saying.
this._startCapture().catch((err) => {
this._startCapture(generation).catch((err) => {
if (generation !== this._generation || this._finalized) return;
this._onError?.('Microphone capture failed: ' + err.message);
this.stop();
});
@@ -342,6 +358,7 @@ const ClaudeVoiceProvider = {
};
this._ws.onmessage = (event) => {
if (generation !== this._generation) return;
let msg;
try {
msg = JSON.parse(event.data);
@@ -361,6 +378,7 @@ const ClaudeVoiceProvider = {
};
this._ws.onclose = (event) => {
if (generation !== this._generation) return;
if (event.code === 4004) {
this._onError?.(this._unavailableMessage(event.reason));
} else if (event.code === 4008) {
@@ -385,16 +403,19 @@ const ClaudeVoiceProvider = {
},
/** Wire mic -> 16 kHz PCM16 frames -> WebSocket. */
async _startCapture() {
async _startCapture(generation = this._generation) {
if (generation !== this._generation || this._finalized) return;
const Ctx = window.AudioContext || window.webkitAudioContext;
// Ask for 16 kHz directly so the browser resamples; Safari may hand back its
// own rate, which _pcmFromFloat32 then downsamples to match.
this._audioContext = new Ctx({ sampleRate: 16000 });
if (this._audioContext.state === 'suspended') await this._audioContext.resume();
if (generation !== this._generation || this._finalized) return;
this._sourceNode = this._audioContext.createMediaStreamSource(this._stream);
if (this._audioContext.audioWorklet) {
await this._audioContext.audioWorklet.addModule(this._workletUrl());
if (generation !== this._generation || this._finalized) return;
this._workletNode = new AudioWorkletNode(this._audioContext, 'pcm-frame-processor');
this._workletNode.port.onmessage = (event) => this._sendAudio(event.data);
this._sourceNode.connect(this._workletNode);
@@ -455,7 +476,10 @@ const ClaudeVoiceProvider = {
_resetSilenceTimeout() {
clearTimeout(this._silenceTimeout);
this._silenceTimeout = setTimeout(() => this.stop(), this.SILENCE_MS);
this._silenceTimeout = null;
const delay = VoiceInput.getSilenceTimeoutMs(this.SILENCE_MS);
if (delay === 0) return;
this._silenceTimeout = setTimeout(() => this.stop(), delay);
},
/**
@@ -469,6 +493,13 @@ const ClaudeVoiceProvider = {
if (this._finalized) return;
this._finalized = true;
this._stopCapture();
// Bound both manual and silence-triggered finalization on a dead connection.
// Repeated stop requests return above, so they cannot extend this deadline.
this._finalizeTimeout = setTimeout(() => {
const onEnd = this._onEnd;
this._cleanup();
onEnd?.();
}, 5000);
if (this._ws?.readyState === WebSocket.OPEN) {
try {
this._ws.send(JSON.stringify({ t: 'finalize' }));
@@ -510,15 +541,19 @@ const ClaudeVoiceProvider = {
/** Hard stop: drop the socket without waiting for a final transcript. */
_cleanup() {
this._generation++;
this._finalized = true;
clearTimeout(this._finalizeTimeout);
this._finalizeTimeout = null;
clearTimeout(this._silenceTimeout);
this._silenceTimeout = null;
this._stopCapture();
if (this._ws) {
this._ws.onopen = null;
this._ws.onclose = null;
this._ws.onmessage = null;
this._ws.onerror = null;
if (this._ws.readyState === WebSocket.OPEN) {
if (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING) {
try { this._ws.close(1000); } catch (_e) { /* ignore */ }
}
this._ws = null;
@@ -537,18 +572,27 @@ const ClaudeVoiceProvider = {
* Inserts final text into the active session (user presses Enter to submit).
*/
const VoiceInput = {
SILENCE_TIMEOUT_SECONDS: Object.freeze([0, 3, 6, 15, 30]),
WEB_SPEECH_INSTANT_END_MS: 1000, // an empty run shorter than this counts toward the retry cap
recognition: null,
isRecording: false,
supported: false,
silenceTimeout: null,
previewEl: null,
_lastTranscript: '',
_stabilityTimer: null,
_accumulatedFinal: '',
_activeProvider: null, // 'deepgram' | 'webspeech' | null
_pendingInterim: '',
_webSpeechCommitted: '', // Text from completed native recognition runs.
_webSpeechSingleUtterance: false,
_webSpeechSoundActive: false,
_webSpeechActivityUntil: 0,
_webSpeechStopping: false,
_webSpeechStopTimer: null,
_webSpeechRestartTimer: null,
_activeProvider: null, // 'claude' | 'deepgram' | 'webspeech' | null
_recordingStartedAt: 0, // timestamp when recording started
_retryCount: 0, // auto-retry counter for premature Web Speech API ends
_hasReceivedResult: false, // whether any speech result came in this session
_retryCount: 0, // consecutive Web Speech runs that ended empty right after starting
_hasReceivedResult: false, // whether this native recognition run received speech
_webSpeechRunStartedAt: 0, // when the current native recognition run was started
_durationInterval: null, // timer for updating elapsed time display
_analyser: null, // AudioContext analyser for level meter
_analyserSource: null, // MediaStreamSource for level meter
@@ -599,6 +643,12 @@ const VoiceInput = {
}
},
/** Seconds in settings; zero means stop only when the user asks. */
getSilenceTimeoutMs(fallback) {
const seconds = this._getDeepgramConfig().silenceTimeout;
return this.SILENCE_TIMEOUT_SECONDS.includes(seconds) ? seconds * 1000 : fallback;
},
_saveDeepgramConfig(config) {
localStorage.setItem('codeman-voice-settings', JSON.stringify(config));
},
@@ -648,7 +698,18 @@ const VoiceInput = {
if (!this.supported) return;
this.recognition = new SR();
this.recognition.continuous = true;
// Android's continuous bridge promotes growing partials to separate finals.
// Single-utterance mode keeps refinements provisional; onend resumes the mic
// while our own silence deadline controls the overall dictation session.
// Android tablets in desktop-site mode may identify as touch-capable Linux.
// ChromeOS also reports Linux but says CrOS, so it keeps continuous mode.
// A Linux laptop with a touchscreen still matches; single-utterance works
// there too, with one native restart per phrase.
const ua = navigator.userAgent || '';
this._webSpeechSingleUtterance = navigator.userAgentData?.platform === 'Android'
|| /Android/i.test(ua)
|| (navigator.maxTouchPoints > 0 && /Linux/i.test(navigator.platform || '') && !/CrOS/.test(ua));
this.recognition.continuous = !this._webSpeechSingleUtterance;
this.recognition.interimResults = true;
this.recognition.lang = 'en-US';
this.recognition.maxAlternatives = 1;
@@ -656,6 +717,8 @@ const VoiceInput = {
this.recognition.onresult = (e) => this._onWebSpeechResult(e);
this.recognition.onerror = (e) => this._onWebSpeechError(e);
this.recognition.onend = () => this._onWebSpeechEnd();
this.recognition.onsoundstart = this.recognition.onspeechstart = () => this._onWebSpeechSound(true);
this.recognition.onsoundend = this.recognition.onspeechend = this.recognition.onaudioend = () => this._onWebSpeechSound(false);
},
toggle() {
@@ -706,7 +769,7 @@ const VoiceInput = {
this.isRecording = true;
this._activeProvider = 'claude';
this._accumulatedFinal = '';
this._lastTranscript = '';
this._pendingInterim = '';
this._hasReceivedResult = false;
this._recordingStartedAt = Date.now();
this._updateButtons('recording');
@@ -725,25 +788,25 @@ const VoiceInput = {
onResult: (text, isFinal) => {
if (!this.isRecording) return;
this._hasReceivedResult = true;
// Each frame is the WHOLE running transcript, so replace rather than append.
this._accumulatedFinal = text;
// Interims replace the current utterance; each endpoint commits it once.
if (isFinal) {
this._hidePreview();
this._insertText(text);
this.stop();
this._accumulatedFinal = [this._accumulatedFinal, text].filter(Boolean).join(' ');
this._pendingInterim = '';
if (ClaudeVoiceProvider._finalized) this.stop(true);
else this._showPreview(this._accumulatedFinal, 'claude');
} else {
this._showPreview(text, 'claude');
this._pendingInterim = (this._accumulatedFinal ? ' ' : '') + text;
this._showPreview(this._accumulatedFinal + this._pendingInterim, 'claude');
}
},
onError: (msg) => {
const wasRecording = this.isRecording;
this.stop();
this.stop(true);
if (wasRecording) app.showToast(msg, 'error');
},
onEnd: () => {
if (this.isRecording) {
if (this._accumulatedFinal) this._insertText(this._accumulatedFinal);
this.stop();
this.stop(true);
}
}
});
@@ -756,7 +819,7 @@ const VoiceInput = {
this.isRecording = true;
this._activeProvider = 'deepgram';
this._accumulatedFinal = '';
this._lastTranscript = '';
this._pendingInterim = '';
this._hasReceivedResult = false;
this._recordingStartedAt = Date.now();
this._updateButtons('recording');
@@ -777,12 +840,12 @@ const VoiceInput = {
if (!this.isRecording) return;
this._hasReceivedResult = true;
if (isFinal) {
this._accumulatedFinal += text;
this._hidePreview();
this._insertText(this._accumulatedFinal);
this.stop();
this._accumulatedFinal += (this._accumulatedFinal ? ' ' : '') + text;
this._pendingInterim = '';
this._showPreview(this._accumulatedFinal, 'deepgram');
} else {
const display = this._accumulatedFinal + text;
this._pendingInterim = (this._accumulatedFinal ? ' ' : '') + text;
const display = this._accumulatedFinal + this._pendingInterim;
this._showPreview(display, 'deepgram');
}
},
@@ -793,9 +856,6 @@ const VoiceInput = {
},
onEnd: () => {
if (this.isRecording) {
if (this._accumulatedFinal) {
this._insertText(this._accumulatedFinal);
}
this.stop();
}
}
@@ -818,44 +878,74 @@ const VoiceInput = {
}
this.isRecording = true;
this._activeProvider = 'webspeech';
this._retryCount = 0;
this._webSpeechCommitted = '';
this._webSpeechSoundActive = false;
this._webSpeechActivityUntil = 0;
this._accumulatedFinal = '';
this._lastTranscript = '';
this._pendingInterim = '';
this._hasReceivedResult = false;
this._recordingStartedAt = Date.now();
this._updateButtons('recording');
this._showPreview('Listening...');
this._startDurationTimer();
try {
this._webSpeechRunStartedAt = Date.now();
this.recognition.start();
} catch (e) {
// InvalidStateError = already started — ignore. Other errors = genuine failure.
if (e.name !== 'InvalidStateError') {
this.stop();
this.stop(true);
app.showToast('Voice input failed to start: ' + e.message, 'error');
return;
}
}
this._resetSilenceTimeout();
// Get mic stream for level meter (non-blocking — level meter is cosmetic)
navigator.mediaDevices?.getUserMedia({ audio: true }).then(stream => {
if (this.isRecording && this._activeProvider === 'webspeech') {
this._webSpeechStream = stream;
this._startLevelMeter(stream);
} else {
stream.getTracks().forEach(t => t.stop());
}
}).catch(() => { /* level meter just won't show */ });
// SpeechRecognition owns its microphone. A second getUserMedia capture for
// a cosmetic meter can take audio away from Android's recognition service.
// Streaming providers still meter the same stream they transcribe.
this._drawLevelMeter();
// Haptic feedback on mobile
if (navigator.vibrate) navigator.vibrate(50);
},
stop() {
stop(providerFinished = false) {
if (!this.isRecording) return;
if (this._webSpeechRestartTimer !== null) {
clearTimeout(this._webSpeechRestartTimer);
this._webSpeechRestartTimer = null;
// The recognizer already ended; no onend remains to wait for.
providerFinished = true;
}
// Claude delivers its final transcript after finalize. Keep accepting it
// until that frame (or onEnd) completes this recording.
if (this._activeProvider === 'claude' && !providerFinished && ClaudeVoiceProvider._ws?.readyState === WebSocket.OPEN) {
ClaudeVoiceProvider.stop();
return;
}
if (this._activeProvider === 'webspeech' && !providerFinished) {
if (this._webSpeechStopping) return;
this._webSpeechStopping = true;
clearTimeout(this.silenceTimeout);
// stop() requests a result; Android may deliver it only before onend.
// Keep accepting results, with a backstop for engines that never end.
this._webSpeechStopTimer = setTimeout(() => {
try { this.recognition?.abort(); } catch (_e) { /* already ended */ }
this.stop(true);
}, 3000);
try { this.recognition?.stop(); } catch (_e) { this.stop(true); }
return;
}
clearTimeout(this._webSpeechStopTimer);
this._webSpeechStopTimer = null;
this._webSpeechStopping = false;
this.isRecording = false;
const text = this._accumulatedFinal + this._pendingInterim;
this._accumulatedFinal = '';
this._pendingInterim = '';
if (text) this._insertText(text);
clearTimeout(this.silenceTimeout);
clearTimeout(this._stabilityTimer);
this.silenceTimeout = null;
this._stabilityTimer = null;
this._retryCount = 0;
this._stopDurationTimer();
this._stopLevelMeter();
@@ -863,21 +953,10 @@ const VoiceInput = {
this._hidePreview();
if (this._activeProvider === 'claude') {
// Finalize, don't hang up: the last transcript arrives after the audio does.
ClaudeVoiceProvider.stop();
// Completion received, or forced teardown: callbacks must not outlive it.
ClaudeVoiceProvider._cleanup();
} else if (this._activeProvider === 'deepgram') {
DeepgramProvider.stop();
} else if (this._activeProvider === 'webspeech') {
try {
this.recognition?.stop();
} catch (_e) {
// Already stopped — ignore
}
// Stop the mic stream we opened for the level meter
if (this._webSpeechStream) {
this._webSpeechStream.getTracks().forEach(t => t.stop());
this._webSpeechStream = null;
}
}
this._activeProvider = null;
@@ -885,41 +964,44 @@ const VoiceInput = {
if (navigator.vibrate) navigator.vibrate([30, 50, 30]);
},
_onWebSpeechSound(active) {
if (!this.isRecording || this._activeProvider !== 'webspeech' || this._webSpeechStopping) return;
this._webSpeechSoundActive = active;
if (!active) this._webSpeechActivityUntil = 0;
},
_onWebSpeechResult(event) {
if (!this.isRecording) return;
this._hasReceivedResult = true;
this._resetSilenceTimeout();
let interim = '';
let finalText = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
finalText += transcript;
} else {
interim += transcript;
}
}
if (finalText) {
this._accumulatedFinal += finalText;
this._hidePreview();
this._insertText(this._accumulatedFinal);
this.stop();
} else if (interim) {
const display = this._accumulatedFinal + interim;
this._showPreview(display);
// iOS Safari workaround: isFinal is always false.
// Detect when interim results stop changing for 750ms → treat as final.
this._iosStabilityCheck(interim);
// results is the current run's complete snapshot, not a list of new words.
// Rebuild by result position so repeats/corrections replace the same text.
// Never deduplicate by text: "go go" and repeated phrases can be intentional.
const finals = [];
const interims = [];
for (let i = 0; i < event.results.length; i++) {
const result = event.results[i];
const text = result[0]?.transcript?.trim();
if (text) (result.isFinal ? finals : interims).push(text);
}
this._accumulatedFinal = [this._webSpeechCommitted, ...finals].filter(Boolean).join(' ');
const interim = interims.join(' ');
this._hasReceivedResult ||= finals.length > 0 || !!interim;
this._pendingInterim = interim ? (this._accumulatedFinal ? ' ' : '') + interim : '';
this._showPreview(this._accumulatedFinal + this._pendingInterim);
// Some engines omit sound events; transcript updates are also real evidence
// of speech activity. This is an activity animation, not an amplitude meter.
if (finals.length || interim) this._webSpeechActivityUntil = Date.now() + 600;
},
_onWebSpeechError(event) {
// During auto-retry, 'aborted' and 'no-speech' errors are expected — ignore them
if (this._retryCount > 0 && (event.error === 'aborted' || event.error === 'no-speech')) return;
// Browser silence events must not override an extended/manual deadline.
if (event.error === 'no-speech' && this.getSilenceTimeoutMs(3000) !== 3000) return;
const wasRecording = this.isRecording;
this.stop();
this.stop(true);
if (!wasRecording) return;
switch (event.error) {
@@ -943,27 +1025,45 @@ const VoiceInput = {
_onWebSpeechEnd() {
// Recognition ended (browser auto-stopped or we called stop())
if (!this.isRecording) return;
this._onWebSpeechSound(false);
if (this._webSpeechStopping) {
this.stop(true);
return;
}
const elapsed = Date.now() - this._recordingStartedAt;
// Web Speech API often fires onend prematurely on the first attempt (< 500ms, no results).
// Auto-retry up to 2 times to avoid the "needs two clicks" problem.
if (elapsed < 500 && !this._hasReceivedResult && this._retryCount < 2) {
this._retryCount++;
try {
this.recognition.start();
} catch (_e) {
// If restart fails, fall through to stop
if (this._accumulatedFinal) this._insertText(this._accumulatedFinal);
this.stop();
}
// Only a run that ends empty right after start() counts toward the cap: that
// is a recognizer that cannot listen. A silent run of normal length is the
// engine's own no-speech window, and 30 s or Manual must outlast many of them.
const empty = !this._hasReceivedResult;
const instant = empty && Date.now() - this._webSpeechRunStartedAt < this.WEB_SPEECH_INSTANT_END_MS;
this._retryCount = instant ? this._retryCount + 1 : 0;
if (this._retryCount > 2) {
this.stop(true);
return;
}
// Native endpoints must not extend the configured silence deadline.
const delay = this.getSilenceTimeoutMs(3000);
if (empty || this._webSpeechSingleUtterance || delay === 0 || delay > 3000) {
// Commit this run once before the next run resets its result indexes.
this._webSpeechCommitted = (this._accumulatedFinal + this._pendingInterim).trim();
this._accumulatedFinal = this._webSpeechCommitted;
this._pendingInterim = '';
const restart = () => {
this._webSpeechRestartTimer = null;
if (!this.isRecording || this._webSpeechStopping) return;
this._hasReceivedResult = false;
this._webSpeechRunStartedAt = Date.now();
try { this.recognition.start(); } catch (_e) { this.stop(true); }
};
// A broken or unavailable recognizer must not spin forever in Manual mode.
if (empty) this._webSpeechRestartTimer = setTimeout(restart, 250);
else restart();
return;
}
// Genuine end — finalize any accumulated text
if (this._accumulatedFinal) {
this._insertText(this._accumulatedFinal);
}
this.stop();
this.stop(true);
},
/** The session this dictation belongs to (see _targetSessionId). */
@@ -1138,30 +1238,14 @@ const VoiceInput = {
_resetSilenceTimeout() {
clearTimeout(this.silenceTimeout);
this.silenceTimeout = null;
const delay = this.getSilenceTimeoutMs(3000);
if (delay === 0) return;
this.silenceTimeout = setTimeout(() => {
if (this.isRecording) {
// Finalize any accumulated text before stopping
if (this._accumulatedFinal) {
this._insertText(this._accumulatedFinal);
}
this.stop();
}
}, 3000);
},
_iosStabilityCheck(transcript) {
if (transcript !== this._lastTranscript) {
this._lastTranscript = transcript;
clearTimeout(this._stabilityTimer);
this._stabilityTimer = setTimeout(() => {
if (this.isRecording) {
const finalText = this._accumulatedFinal + transcript;
this._hidePreview();
this._insertText(finalText);
this.stop();
}
}, 750);
}
}, delay);
},
_startDurationTimer() {
@@ -1200,6 +1284,8 @@ const VoiceInput = {
},
_stopLevelMeter() {
this._webSpeechSoundActive = false;
this._webSpeechActivityUntil = 0;
if (this._levelAnimFrame) {
cancelAnimationFrame(this._levelAnimFrame);
this._levelAnimFrame = null;
@@ -1216,13 +1302,21 @@ const VoiceInput = {
},
_drawLevelMeter() {
if (!this._analyser || !this.isRecording) return;
const dataArray = new Uint8Array(this._analyser.frequencyBinCount);
this._analyser.getByteFrequencyData(dataArray);
// Compute RMS level 0-1
let sum = 0;
for (let i = 0; i < dataArray.length; i++) sum += dataArray[i] * dataArray[i];
const rms = Math.sqrt(sum / dataArray.length) / 255;
if (!this.isRecording) return;
let rms = 0;
if (this._activeProvider === 'webspeech') {
// The speech API exposes activity events, but no microphone sample level.
const active = !this._webSpeechStopping
&& (this._webSpeechSoundActive || Date.now() < this._webSpeechActivityUntil);
if (active) rms = 0.25 + 0.55 * (1 + Math.sin(Date.now() / 110)) / 2;
} else {
if (!this._analyser) return;
const dataArray = new Uint8Array(this._analyser.frequencyBinCount);
this._analyser.getByteFrequencyData(dataArray);
let sum = 0;
for (let i = 0; i < dataArray.length; i++) sum += dataArray[i] * dataArray[i];
rms = Math.sqrt(sum / dataArray.length) / 255;
}
// Update the level bars in the preview
const barsEl = this.previewEl?.querySelector('.voice-level-bars');
if (barsEl) {
@@ -1252,6 +1346,8 @@ const VoiceInput = {
indicator.innerHTML = '<span class="voice-rec-dot"></span>';
const barsEl = document.createElement('span');
barsEl.className = 'voice-level-bars';
barsEl.setAttribute('aria-label', this._activeProvider === 'webspeech' ? 'Speech activity' : 'Microphone level');
barsEl.title = this._activeProvider === 'webspeech' ? 'Speech activity' : 'Microphone level';
for (let i = 0; i < 5; i++) {
const bar = document.createElement('span');
bar.className = 'voice-level-bar';
@@ -1319,28 +1415,27 @@ const VoiceInput = {
/** Cleanup on SSE reconnect or page unload */
cleanup() {
if (this.isRecording) this.stop();
// Hide the Send button first: stop(true) below may insert the words it
// salvaged and show the button again for them.
this._hideVoiceSendBtn();
if (this.isRecording) {
this.stop(true);
try { this.recognition?.abort(); } catch (_e) { /* already ended */ }
}
DeepgramProvider._cleanup();
ClaudeVoiceProvider._cleanup();
this.recognition = null;
this._activeProvider = null;
this._stopDurationTimer();
this._stopLevelMeter();
if (this._webSpeechStream) {
this._webSpeechStream.getTracks().forEach(t => t.stop());
this._webSpeechStream = null;
}
if (this.previewEl) {
this.previewEl.remove();
this.previewEl = null;
}
clearTimeout(this.silenceTimeout);
clearTimeout(this._stabilityTimer);
this.silenceTimeout = null;
this._stabilityTimer = null;
this._accumulatedFinal = '';
this._lastTranscript = '';
this._pendingInterim = '';
this._retryCount = 0;
this._hasReceivedResult = false;
}
+9 -9
View File
@@ -1,13 +1,13 @@
{
"version": "1.50.0",
"version": "1.50.1",
"highlights": [
"Codeman for Mac (#763)",
"Your MacBook's Apple logo blinks with your agents (#781)",
"Install from the app, with no Terminal and no password (#773)",
"The Files panel follows a worktree outside the session's folder (#780)",
"A coding model that runs on your Mac (#784, experimental)",
"Claude in the cloud, a model on your Mac (#784, experimental)",
"Ralph starts from Session Options (#783)",
"The Codeman agent skill is on by default (#787)"
"A MacBook keeps working with the lid closed (#795)",
"The lid logo breathes while agents work (#794)",
"The logo keeps blinking with the lid closed (#801)",
"Codeman and LogoLight take turns on the logo (#802)",
"Dictation survives a thinking pause (#705)",
"Sync cases across devices (#732)",
"Delete a file from the Files panel (#792)",
"Zoom the path picker's image preview (#791)"
]
}
+31 -4
View File
@@ -87,6 +87,7 @@ import {
settingsWriteErrorResponse,
} from '../settings-writer.js';
import { LinkedCasesWriteRefusedError, linkedCasesPath, mutateLinkedCases } from '../linked-cases-writer.js';
import { dropCaseSettings, renameCaseSettings } from '../case-state-sync.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import type { AuthUser } from '../../types.js';
import { SseEvent } from '../sse-events.js';
@@ -1747,6 +1748,14 @@ export function registerCaseRoutes(
// a stale value is ignored at creation anyway).
// An admin deleting another user's remote/Docker case clears that owner's too.
const forgetCaseDefault = async (caseOwner?: string): Promise<void> => {
// The shared run options (syncCaseState) are the admin's server-wide map: a non-admin's own case of
// the same name must leave them alone. Best-effort, like the default group.
if (isAdmin(req)) {
await mutateSettingsFile((current) => {
const rest = dropCaseSettings(current.caseSettings, name);
return rest ? { ...current, caseSettings: rest } : undefined;
}).catch(() => undefined);
}
const owners = new Set([ownerLayoutKey(ownerFor(req)), ...(caseOwner ? [ownerLayoutKey(caseOwner)] : [])]);
for (const owner of owners) {
try {
@@ -2015,6 +2024,7 @@ export function registerCaseRoutes(
// The settings.json values this rename replaced, set only once it wrote them. A failure puts back
// these keys alone, so a settings save that landed in between is kept.
let settingsUndo: Record<string, unknown> | undefined;
let sharedRenamed = false;
try {
// settings.json is server-wide and caseOrder/lastUsedCase name the admin's cases, so only the
// admin's rename touches it (a non-admin's own rename must not rewrite someone else's list).
@@ -2023,12 +2033,24 @@ export function registerCaseRoutes(
const { before, after } = await mutateSettingsFile((existing) => {
const order = renameInOrder(existing.caseOrder, name, newName);
const last = existing.lastUsedCase === name ? newName : undefined;
if (!order && !last) return undefined;
return { ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) };
const picked = existing.lastPickedCase === name ? newName : undefined;
// The shared run options (syncCaseState) follow the case to its new name.
const shared = renameCaseSettings(existing.caseSettings, name, newName);
if (!order && !last && !picked && !shared) return undefined;
return {
...existing,
...(order ? { caseOrder: order } : {}),
...(last ? { lastUsedCase: last } : {}),
...(picked ? { lastPickedCase: picked } : {}),
...(shared ? { caseSettings: shared } : {}),
};
});
if (after) {
const keys = ['caseOrder', 'lastUsedCase'].filter((k) => after[k] !== before[k]);
// caseSettings is undone by renaming its entry back (below), not by restoring the whole map, so a
// per-case save from another device that landed since is kept.
const keys = ['caseOrder', 'lastUsedCase', 'lastPickedCase'].filter((k) => after[k] !== before[k]);
settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]]));
sharedRenamed = after.caseSettings !== before.caseSettings;
}
}
if (isLinked) {
@@ -2049,7 +2071,12 @@ export function registerCaseRoutes(
// The folder moved but the registries did not follow: put settings.json and the folder back
// rather than leave a case whose name and folder disagree.
const undo = settingsUndo;
if (undo) await mutateSettingsFile((current) => ({ ...current, ...undo })).catch(() => {});
if (undo) {
await mutateSettingsFile((current) => {
const back = sharedRenamed ? renameCaseSettings(current.caseSettings, newName, name) : null;
return { ...current, ...undo, ...(back ? { caseSettings: back } : {}) };
}).catch(() => {});
}
if (moveFolder) {
const movedBack = await fs.rename(newPath, oldPath).then(
() => true,
+102 -2
View File
@@ -1,9 +1,11 @@
/**
* @fileoverview File browser and streaming routes.
* Provides directory listing, file content preview, raw file serving, tail
* streaming, and the File Viewer edit-mode write path (edit=1 read +
* streaming, the File Viewer edit-mode write path (edit=1 read +
* PUT /api/sessions/:id/file-content; policy in src/config/file-editing.ts,
* design in docs/file-viewer-edit-plan.md).
* design in docs/file-viewer-edit-plan.md), and the Files panel's delete
* (DELETE /api/sessions/:id/files?path=: one regular file, the edit route's
* confinement and guards, symbolic links refused).
*/
import { FastifyInstance, type FastifyReply } from 'fastify';
@@ -19,6 +21,7 @@ import type {
FilesystemBrowseEntry,
FilesystemBrowseRoot,
FilesystemPreviewKind,
FileDeleteData,
FileWriteData,
} from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
@@ -2000,6 +2003,103 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
};
});
// The Files panel's delete button: remove ONE regular file from the session's workspace (or from
// its outside worktree, for an absolute path inside it). A write surface like the edit route
// above, so it takes that route's confinement and guards whole (ownership, realpath + workspace boundary, sensitive and blocked trees, `.git`), and adds
// its own rule for links: a symbolic link is REFUSED, never followed. The confinement check
// returns the link's realpath, which is its TARGET, so an `unlink` of that path deletes the file
// the link points at and leaves the link dangling (#529's review: `AGENTS.md -> CLAUDE.md`
// deleted CLAUDE.md). The final component is therefore read with `lstat` on the path as the
// client named it, and only a path whose last part is a plain file goes on; `unlink` never
// follows a final symlink, so a link swapped in after that check is removed rather than its
// target, and the inode is compared again right before the unlink.
app.delete('/api/sessions/:id/files', async (req): Promise<ApiResponse<FileDeleteData>> => {
const { id } = req.params as { id: string };
const { path: filePath } = req.query as { path?: string };
const session = findSessionOrFail(ctx, id, req);
// Ahead of any path check, as on the edit route: the validation below resolves against the
// LOCAL filesystem, so a same-named local directory would take the delete.
if (session.remote) {
throwRouteError(400, ApiErrorCode.INVALID_INPUT, 'Deleting is not supported for remote (SSH) sessions');
}
// An adopted session has no workspace of ours: its folder is the neutral `/tmp`. The older
// file surfaces keep serving it as a standing exception (root CLAUDE.md, Adopted tmux
// sessions), which is not copied to a new surface, least of all one that deletes.
if (session.isAdopted) {
throwRouteError(400, ApiErrorCode.INVALID_INPUT, 'Deleting is not supported for adopted sessions');
}
// A repeated `?path=` arrives as an array.
if (typeof filePath !== 'string' || !filePath) {
throwRouteError(400, ApiErrorCode.INVALID_INPUT, 'Missing path parameter');
}
// #516: the confinement check below runs realpathSync, which freezes the server on a mount
// that stopped answering. Probe the folders it will touch first, bounded.
const scope = await withGuardedOutsideWorktree({
workingDir: session.workingDir,
liveWorktree: session.liveWorktree,
});
const worktreeRoot = scope.liveWorktree?.outside && isAbsolute(filePath) ? scope.liveWorktree.path : null;
for (const folder of worktreeRoot ? [session.workingDir, worktreeRoot] : [session.workingDir]) {
if ((await probePathKind(folder, { pastCap: true })) === 'unknown') {
throwRouteError(
422,
ApiErrorCode.OPERATION_FAILED,
describeUnknownPath('Workspace', folder, { pastCap: true })
);
}
}
const validated = validateLocalSessionFilePath(scope, filePath);
if (!validated) {
// Missing files, traversal and symlink escapes alike, as on every other file route.
throwRouteError(404, ApiErrorCode.NOT_FOUND, 'File not found');
}
const { resolvedPath, relativePath } = validated;
// The path as the client named it: `resolve` keeps an absolute (outside worktree) path as is.
const namedPath = resolve(session.workingDir, filePath);
const guard = await loadAttachmentGuardConfig();
if (
isSensitivePath(resolvedPath) ||
isSensitivePath(namedPath) ||
isBlockedAttachmentPath(resolvedPath, guard.blockedTrees) ||
isBlockedAttachmentPath(namedPath, guard.blockedTrees)
) {
throwRouteError(403, ApiErrorCode.FORBIDDEN, 'Deleting this file is blocked');
}
// Both spellings: a `.git` reached through a linked folder, and a linked `.git`.
if (isDeniedEditRelativePath(relativePath) || isDeniedEditRelativePath(relative(session.workingDir, namedPath))) {
throwRouteError(403, ApiErrorCode.FORBIDDEN, 'Files under .git cannot be deleted');
}
const named = await fs.lstat(namedPath).catch(() => undefined);
if (!named) {
throwRouteError(404, ApiErrorCode.NOT_FOUND, 'File not found');
}
if (named.isSymbolicLink()) {
throwRouteError(
400,
ApiErrorCode.INVALID_INPUT,
'Symbolic links are not deleted from the Files panel (deleting one here would remove the file it points to)'
);
}
if (!named.isFile()) {
throwRouteError(400, ApiErrorCode.INVALID_INPUT, 'Only regular files can be deleted');
}
// The same file the checks were made on: realpath of a path whose last part is no link names
// the same inode. A change in between (the file replaced) refuses rather than guesses.
const real = await fs.lstat(resolvedPath).catch(() => undefined);
if (!real || !real.isFile() || real.ino !== named.ino || real.dev !== named.dev) {
throwRouteError(409, ApiErrorCode.CONFLICT, 'The file changed while it was being deleted; reload and try again');
}
try {
await fs.unlink(resolvedPath);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') throwRouteError(404, ApiErrorCode.NOT_FOUND, 'File not found');
// The errno text names the resolved path, so only its code goes back.
throwRouteError(500, ApiErrorCode.OPERATION_FAILED, `Failed to delete the file (${code ?? 'error'})`);
}
return { success: true, data: { path: filePath } };
});
// Get file content for preview (File Browser)
app.get('/api/sessions/:id/file-content', async (req) => {
const { id } = req.params as { id: string };
+23 -4
View File
@@ -49,6 +49,7 @@ import {
SETTINGS_PATH,
} from '../route-helpers.js';
import { mutateSettingsFile, settingsWriteErrorResponse } from '../settings-writer.js';
import { mergeCaseSettings, SharedCaseLimitError } from '../case-state-sync.js';
import { SseEvent } from '../sse-events.js';
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
@@ -160,8 +161,8 @@ export function resolveSpanUrl(hostHeader: string | undefined, fallbackPort = '3
* What GET /api/settings answers a NON-ADMIN in multi-user mode: settings.json minus what
* belongs to the server's admin rather than to the reader. `voiceSettings.apiKey` is the
* Deepgram key an admin saved, a credential (the reader's browser seeded it into its own
* voice config and dictated on the admin's account); `caseOrder` and `lastUsedCase` name the
* admin's cases. Everything else stays readable, because the page reads the synced feature
* voice config and dictated on the admin's account); `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings` name
* the admin's cases. Everything else stays readable, because the page reads the synced feature
* switches from it. Writes are admin-only there (PUT /api/settings). Exported for unit testing.
*/
export function settingsForNonAdmin(settings: unknown): unknown {
@@ -169,6 +170,8 @@ export function settingsForNonAdmin(settings: unknown): unknown {
const out: Record<string, unknown> = { ...(settings as Record<string, unknown>) };
delete out.caseOrder;
delete out.lastUsedCase;
delete out.lastPickedCase;
delete out.caseSettings;
const voice = out.voiceSettings;
if (voice && typeof voice === 'object' && !Array.isArray(voice) && 'apiKey' in voice) {
const voiceOut: Record<string, unknown> = { ...(voice as Record<string, unknown>) };
@@ -1141,8 +1144,18 @@ export function registerSystemRoutes(
// It must never write tunnelEnabled:true: a concurrent stop has written false by now,
// and true on disk would show a stopped tunnel as on. The stored value stays as it is.
if (settings.tunnelEnabled === true && !tunnelEnableAuthorized) delete settingsToStore.tunnelEnabled;
const { before: existing } = await mutateSettingsFile((current) => ({ ...current, ...settingsToStore }));
const merged = { ...existing, ...settingsToStore };
// caseSettings merges per case inside the serialised writer, so two devices saving different cases both land.
const { caseSettings: incomingCases, ...plainSettings } = settingsToStore;
// The map is read and written only while syncCaseState is on, so a PUT that carries it with the setting off
// (as stored, or as this same PUT sets it) leaves the stored map alone.
const { before: existing, after } = await mutateSettingsFile((current) => ({
...current,
...plainSettings,
...(incomingCases && (plainSettings.syncCaseState ?? current.syncCaseState) === true
? { caseSettings: mergeCaseSettings(current.caseSettings, incomingCases as never) }
: {}),
}));
const merged = after ?? { ...existing, ...plainSettings };
// tmux 3.7+ resizes tracked panes; older versions apply this to new panes.
// Already-evicted history cannot be recovered on either version.
@@ -1244,6 +1257,12 @@ export function registerSystemRoutes(
return {};
} catch (err) {
if (err instanceof SharedCaseLimitError) {
throw Object.assign(err, {
statusCode: 400,
body: createErrorResponse(ApiErrorCode.INVALID_INPUT, err.message),
});
}
return settingsWriteErrorResponse(err);
}
});
+14
View File
@@ -1461,6 +1461,18 @@ export const SettingsUpdateSchema = z
defaultClaudeMdPath: z.string().max(500).optional(),
defaultWorkingDir: z.string().max(500).optional(),
lastUsedCase: z.string().max(200).optional(),
/** The case a person last picked in a case picker; what other devices follow while `syncCaseState` is on. */
lastPickedCase: z.string().max(200).optional(),
/**
* Opt-in, SYNCED, default OFF: per-case run options (Agent Teams, Opus 1M) are kept here instead of
* only in each browser, and a device follows the selected case another device chose.
*/
syncCaseState: z.boolean().optional(),
/** Per-case run options, by case name; read and written only while `syncCaseState` is on. */
caseSettings: z
.record(z.string().max(200), z.object({ agentTeams: z.boolean(), opusContext1m: z.boolean() }).strict())
.refine((m) => Object.keys(m).length <= 500, 'too many cases')
.optional(),
// Feature toggles
ralphTrackerEnabled: z.boolean().optional(),
subagentTrackingEnabled: z.boolean().optional(),
@@ -1783,6 +1795,8 @@ export const SettingsUpdateSchema = z
language: z.string().max(20).optional(),
keyterms: z.string().max(500).optional(),
insertMode: z.string().max(20).optional(),
/** Silence timeout in seconds; 0 disables automatic stopping. Omitted keeps provider defaults. */
silenceTimeout: z.union([z.literal(0), z.literal(3), z.literal(6), z.literal(15), z.literal(30)]).optional(),
})
.optional(),
// Run mode preference (cross-device sync)
+5 -4
View File
@@ -13,9 +13,10 @@
* - `{"type":"KeepAlive"}` on open and every 8s, or upstream drops the socket
* between utterances.
* - Audio frames go up as raw binary.
* - Downstream, `TranscriptText`/`TranscriptInterim` carry the RUNNING transcript
* (each frame supersedes the previous one — they are not deltas to concatenate),
* and `TranscriptEndpoint` promotes the pending interim to final.
* - Downstream, `TranscriptText`/`TranscriptInterim` carry the current utterance
* so far (each frame supersedes the previous one; they are not deltas to
* concatenate), and `TranscriptEndpoint` promotes the pending interim to final,
* which ends that utterance.
* - `{"type":"CloseStream"}` finalizes; the endpoint frame that follows is the
* last transcript, so `finalize()` waits briefly for it rather than closing.
*
@@ -244,7 +245,7 @@ export class VoiceStreamRelay {
switch (msg.type) {
case 'TranscriptText':
case 'TranscriptInterim': {
// Each frame is the whole running transcript, not a delta.
// Each frame is the current utterance so far, not a delta; endpoints commit it.
if (typeof msg.data === 'string' && msg.data) {
this.pendingTranscript = msg.data;
this.opts.onTranscript(msg.data, false);
+1 -1
View File
@@ -96,7 +96,7 @@ async function boot(stored?: Settings) {
};
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
var KeyboardAccessoryBar = { setMode() {} };
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var VoiceInput = { SILENCE_TIMEOUT_SECONDS: [0, 3, 6, 15, 30], _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
var DEFAULT_VOICE_KEYTERMS = '';`,
...SCRIPTS,
+5 -1
View File
@@ -173,7 +173,11 @@ describe('createConfigBackup', () => {
'hostId',
],
['users.json', (n: number) => JSON.stringify({ users: [{ name: 'a', role: 'admin', lastLoginAt: n }] }), 'role'],
['settings.json', (n: number) => JSON.stringify({ displayName: 'x', lastUsedCase: `case-${n}` }), 'displayName'],
[
'settings.json',
(n: number) => JSON.stringify({ displayName: 'x', lastUsedCase: `case-${n}`, lastPickedCase: `case-${n}` }),
'displayName',
],
[
'intents.json',
(n: number) => JSON.stringify({ profiles: [{ key: 'k', goals: 'g', updatedAt: n, recentPrompts: [`p${n}`] }] }),
+234
View File
@@ -0,0 +1,234 @@
/**
* @fileoverview Runs the real lid helper of the Codeman app for Mac
* (packages/desktop/macos/keep-awake-lid.sh) in bash against a stub `pmset` that
* records what it was asked to do. The helper runs as root from a LaunchDaemon, so the
* cases that matter are the ones where it must NOT act: a stale, dead, foreign or
* symlinked request, a Mac on battery, and an administrator's own `disablesleep 1`,
* which it must never switch off.
*
* Only the BSD/GNU-portable tools the script uses (find -mmin, ps -o uid=, ls -ln) run
* here, so this passes on Linux and macOS alike. The app embeds the same file
* (include_str! in packages/desktop/src/keep_awake.rs), so this is the script that
* gets installed. Port: none.
*/
import { spawnSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
utimesSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const HELPER = fileURLToPath(new URL('../packages/desktop/macos/keep-awake-lid.sh', import.meta.url));
let dir: string;
let stateDir: string;
let request: string;
/**
* Stub pmset: `-g` prints the stored SleepDisabled, `-g batt` the stored power source,
* `-a disablesleep N` stores N and logs it.
*/
function writeStubPmset(initial: '0' | '1' | null, power: 'AC Power' | 'Battery Power' | null = 'AC Power') {
const value = join(dir, 'sleepdisabled');
if (initial !== null) writeFileSync(value, initial);
const source = join(dir, 'power');
if (power !== null) writeFileSync(source, power);
const stub = join(dir, 'pmset');
writeFileSync(
stub,
`#!/bin/bash
if [ "$1" = "-g" ] && [ "$2" = "batt" ]; then
[ -f "${source}" ] || exit 1
echo "Now drawing from '$(cat "${source}")'"
exit 0
fi
if [ "$1" = "-g" ]; then
echo "System-wide power settings:"
[ -f "${value}" ] && printf ' SleepDisabled\\t\\t%s\\n' "$(cat "${value}")"
echo "Currently in use:"
echo " sleep 1"
exit 0
fi
if [ "$1" = "-a" ] && [ "$2" = "disablesleep" ]; then
printf '%s' "$3" > "${value}"
echo "disablesleep $3" >> "${join(dir, 'pmset.log')}"
exit 0
fi
exit 1
`
);
chmodSync(stub, 0o755);
return stub;
}
const setPower = (power: 'AC Power' | 'Battery Power') => writeFileSync(join(dir, 'power'), power);
function run() {
const result = spawnSync('bash', [HELPER, request], {
encoding: 'utf-8',
env: {
...process.env,
CODEMAN_KEEPAWAKE_PMSET: join(dir, 'pmset'),
CODEMAN_KEEPAWAKE_STATE_DIR: stateDir,
},
});
expect(result.status, result.stderr).toBe(0);
}
const calls = () => (existsSync(join(dir, 'pmset.log')) ? readFileSync(join(dir, 'pmset.log'), 'utf-8') : '');
const sleepDisabled = () => readFileSync(join(dir, 'sleepdisabled'), 'utf-8');
const owned = () => existsSync(join(stateDir, 'keep-awake.owned'));
/** What the app writes: its own pid and a newline. This test process stands in for it. */
const requestFromLiveApp = () => writeFileSync(request, `${process.pid}\n`);
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'keep-awake-lid-'));
stateDir = join(dir, 'state');
request = join(dir, 'keep-awake-lid.pid');
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe('keep-awake-lid.sh', () => {
it('does nothing without a request', () => {
writeStubPmset('0');
run();
expect(calls()).toBe('');
expect(owned()).toBe(false);
});
it('applies disablesleep for a fresh request from a live process, then undoes it when the request goes', () => {
writeStubPmset('0');
requestFromLiveApp();
run();
expect(sleepDisabled()).toBe('1');
expect(owned()).toBe(true);
run(); // steady state: no repeated pmset call
expect(calls()).toBe('disablesleep 1\n');
rmSync(request);
run();
expect(sleepDisabled()).toBe('0');
expect(owned()).toBe(false);
expect(calls()).toBe('disablesleep 1\ndisablesleep 0\n');
});
it('never acts on battery, and lets go when the adapter is pulled', () => {
writeStubPmset('0', 'Battery Power');
requestFromLiveApp();
run();
expect(calls()).toBe('');
setPower('AC Power');
run();
expect(sleepDisabled()).toBe('1');
setPower('Battery Power');
run();
expect(sleepDisabled()).toBe('0');
expect(owned()).toBe(false);
});
it('reads an unanswerable power source as battery', () => {
writeStubPmset('0', null);
requestFromLiveApp();
run();
expect(calls()).toBe('');
});
it("never switches off an administrator's own disablesleep", () => {
writeStubPmset('1');
requestFromLiveApp();
run();
expect(calls()).toBe('');
expect(owned()).toBe(false);
rmSync(request);
run();
expect(sleepDisabled()).toBe('1');
expect(calls()).toBe('');
});
it("leaves an administrator's own disablesleep on when the adapter is pulled", () => {
writeStubPmset('1', 'Battery Power');
requestFromLiveApp();
run();
expect(calls()).toBe('');
expect(sleepDisabled()).toBe('1');
});
it('treats an absent SleepDisabled line as 0', () => {
writeStubPmset(null);
requestFromLiveApp();
run();
expect(sleepDisabled()).toBe('1');
expect(owned()).toBe(true);
});
it('re-applies after a reboot or OS update reset it, while the request stands', () => {
writeStubPmset('0');
requestFromLiveApp();
run();
writeFileSync(join(dir, 'sleepdisabled'), '0'); // the reset
run();
expect(sleepDisabled()).toBe('1');
});
it('ignores a stale request (an app that hangs) and releases', () => {
writeStubPmset('0');
requestFromLiveApp();
run();
expect(sleepDisabled()).toBe('1');
const threeMinutesAgo = (Date.now() - 3 * 60_000) / 1000;
utimesSync(request, threeMinutesAgo, threeMinutesAgo);
run();
expect(sleepDisabled()).toBe('0');
expect(owned()).toBe(false);
});
it('releases at the next run once the app is gone, however fresh its file', () => {
writeStubPmset('0');
requestFromLiveApp();
run();
expect(sleepDisabled()).toBe('1');
const gone = spawnSync('true').pid;
writeFileSync(request, `${gone}\n`);
run();
expect(sleepDisabled()).toBe('0');
expect(owned()).toBe(false);
});
it('ignores a request whose pid is not running', () => {
writeStubPmset('0');
const gone = spawnSync('true').pid;
writeFileSync(request, `${gone}\n`);
run();
expect(calls()).toBe('');
});
it('ignores garbage and refuses a symlinked request', () => {
writeStubPmset('0');
writeFileSync(request, 'not-a-pid\n');
run();
expect(calls()).toBe('');
rmSync(request);
const real = join(dir, 'elsewhere.pid');
writeFileSync(real, `${process.pid}\n`);
symlinkSync(real, request);
run();
expect(calls()).toBe('');
});
});
+4
View File
@@ -30,6 +30,9 @@ interface FakeRow {
dataset: Record<string, string>;
addEventListener: (type: string, handler: ClickHandler) => void;
click: () => void;
// The row's delete button is looked up per row; the fake rows carry none (file-delete-ui.test.ts
// drives it over a real DOM).
querySelector: (selector: string) => null;
}
interface FakeElement {
@@ -105,6 +108,7 @@ function fakeElement(initialClasses: string[] = []): FakeElement {
click() {
clickHandler?.();
},
querySelector: () => null,
});
}
},
+264
View File
@@ -0,0 +1,264 @@
/**
* @fileoverview The Files panel's delete button (salvaged from #529), in the browser module.
*
* #529 first rendered the button with an inline `onclick` that quoted the path inside
* `decodeURIComponent('...')`. `encodeURIComponent` leaves `'` alone, so a file named
* `x'-alert(1)-'.txt` ran script in Codeman's origin, and `it's.txt` was a syntax error whose
* button did nothing. Workspace names come from agents and cloned repos, so the click is now
* bound from the row's escaped `data-path`.
*
* The DOM is jsdom with `runScripts: 'dangerously'` on purpose: jsdom then compiles inline event
* attributes the way a browser does, with `window.app` and `window.alert` present, so the old
* markup would really call `alert` here and this test would fail on it. The escaper is the real
* one from constants.js, not a stub. panels-ui.js loads via `vm` as in
* test/file-browser-incomplete.test.ts. Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const PANELS = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
const CONSTANTS = readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8');
const STYLES = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
// The shipped escaper: its map, its pattern and the function, cut from constants.js.
const ESCAPE_START = CONSTANTS.indexOf('const _htmlEscapeMap');
const ESCAPE_END = CONSTANTS.indexOf('\n}\n', CONSTANTS.indexOf('function escapeHtml(')) + 3;
const ESCAPE_SRC = CONSTANTS.slice(ESCAPE_START, ESCAPE_END);
const HOSTILE = ["x'-alert(1)-'.txt", "it's.txt", 'a "b" <i>c</i> & d.txt', "');alert(2);('.txt"];
const SESSION = 'sess-1';
const dom = new JSDOM(
`<div id="fileBrowserPanel" class="visible"></div>
<input id="fileBrowserSearch">
<div id="fileBrowserStatus"></div>
<div id="fileBrowserTree"></div>`,
{ runScripts: 'dangerously' }
);
afterAll(() => dom.window.close());
const document = dom.window.document;
const win = dom.window as unknown as Record<string, unknown>;
const treeEl = () => document.getElementById('fileBrowserTree') as HTMLElement;
interface FakeResponse {
ok: boolean;
status: number;
json: () => Promise<unknown>;
}
const reply = (body: unknown, status = 200): FakeResponse => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
});
function loadPanel(
options: {
confirm?: (message: string) => boolean;
fetch?: (url: string, init?: { method?: string }) => Promise<FakeResponse>;
session?: Record<string, unknown>;
} = {}
) {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
const context = vm.createContext({
CodemanApp,
console,
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn() },
// The search debounce, collapsed so the re-run lands on the next turn.
setTimeout: (fn: () => void) => setTimeout(fn, 0),
clearTimeout,
CodemanBase: { url: (p: string) => p },
confirm: vi.fn(options.confirm ?? (() => true)),
fetch: vi.fn(options.fetch ?? (async () => reply({ success: true, data: {} }))),
});
vm.runInContext(ESCAPE_SRC, context, { filename: 'constants.js (escapeHtml)' });
vm.runInContext(PANELS, context, { filename: 'panels-ui.js' });
const app = new CodemanApp();
app.$ = (id: string) => document.getElementById(id);
app.activeSessionId = SESSION;
app.sessions = new Map([[SESSION, options.session ?? { id: SESSION }]]);
app.fileBrowserExpandedDirs = new Set(['src']);
app.fileBrowserShowHidden = false;
app.getFileIcon = () => '';
app.formatFileSize = () => '';
app.openFilePreview = vi.fn();
app.showToast = vi.fn();
// The sentinel the old inline handler reached: `app.deleteFileFromBrowser(...)` ran in the page.
win.app = app;
win.alert = vi.fn();
return { app, context: context as Record<string, any> };
}
const fileNode = (name: string) => ({ name, path: `src/${name}`, type: 'file', size: 1, extension: 'txt' });
const treeEnvelope = (names: string[]) => ({
success: true,
data: {
root: '/repo',
tree: [{ name: 'src', path: 'src', type: 'directory', children: names.map(fileNode) }],
totalFiles: names.length,
totalDirectories: 1,
truncated: false,
},
});
const searchEnvelope = (names: string[]) => ({
success: true,
data: { mode: 'search', matches: names.map(fileNode), matchCount: names.length, truncated: false },
});
const rowFor = (path: string) =>
[...treeEl().querySelectorAll<HTMLElement>('.file-tree-item')].find((row) => row.dataset.path === path);
const deleteButtonFor = (path: string) => rowFor(path)?.querySelector<HTMLButtonElement>('.file-tree-delete');
function renderTree(app: Record<string, any>, names: string[]) {
app.fileBrowserData = app._validateFileBrowserTreeEnvelope(treeEnvelope(names));
app.renderFileBrowserTree(SESSION);
}
function renderSearch(app: Record<string, any>, names: string[]) {
app._renderFileBrowserSearchResults(names.map(fileNode), SESSION, { matchCount: names.length, truncated: false });
}
describe('Files panel delete button: no inline handler, the exact path', () => {
it.each([
['tree', renderTree],
['search results', renderSearch],
])('%s: a click deletes exactly the hostile path and runs nothing', (_label, render) => {
const { app } = loadPanel();
app.deleteFileFromBrowser = vi.fn();
render(app, HOSTILE);
expect(treeEl().innerHTML).not.toContain('decodeURIComponent');
for (const name of HOSTILE) {
const button = deleteButtonFor(`src/${name}`);
expect(button, name).toBeTruthy();
expect(button!.getAttribute('onclick')).toBeNull();
expect(button!.type).toBe('button');
button!.click();
expect(app.deleteFileFromBrowser).toHaveBeenLastCalledWith(SESSION, `src/${name}`);
}
expect(app.deleteFileFromBrowser).toHaveBeenCalledTimes(HOSTILE.length);
expect(win.alert).not.toHaveBeenCalled();
// The click stays on the button: the row behind it does not open the file.
expect(app.openFilePreview).not.toHaveBeenCalled();
// The row itself still opens it.
rowFor(`src/${HOSTILE[0]}`)!.click();
expect(app.openFilePreview).toHaveBeenCalledWith(`src/${HOSTILE[0]}`, SESSION);
});
it('labels the button with the escaped name, as text', () => {
const { app } = loadPanel();
renderTree(app, ['a "b" <i>c</i> & d.txt']);
const button = deleteButtonFor('src/a "b" <i>c</i> & d.txt')!;
expect(button.getAttribute('aria-label')).toBe('Delete a "b" <i>c</i> & d.txt');
expect(rowFor('src/a "b" <i>c</i> & d.txt')!.querySelector('i')).toBeNull();
});
it('offers no delete button on a folder row', () => {
const { app } = loadPanel();
renderTree(app, ['a.txt']);
expect(rowFor('src')!.querySelector('.file-tree-delete')).toBeNull();
expect(deleteButtonFor('src/a.txt')).toBeTruthy();
});
it.each([
['a remote (SSH) session', { id: SESSION, remote: { host: 'h' } }],
['an adopted session', { id: SESSION, adopt: { kind: 'local' } }],
])('offers none for %s, which the route refuses', (_label, session) => {
const { app } = loadPanel({ session });
renderTree(app, ['a.txt']);
expect(rowFor('src/a.txt')).toBeTruthy();
expect(deleteButtonFor('src/a.txt')).toBeNull();
renderSearch(app, ['a.txt']);
expect(rowFor('src/a.txt')).toBeTruthy();
expect(deleteButtonFor('src/a.txt')).toBeNull();
});
});
describe('deleteFileFromBrowser', () => {
it('asks first and sends nothing when the answer is no', async () => {
const { app, context } = loadPanel({ confirm: () => false });
await app.deleteFileFromBrowser(SESSION, "src/it's.txt");
expect(context.confirm).toHaveBeenCalledWith("Delete the file src/it's.txt? This cannot be undone.");
expect(context.fetch).not.toHaveBeenCalled();
});
it('sends one DELETE with the encoded path, then reloads keeping the query', async () => {
const { app, context } = loadPanel({ fetch: async () => reply({ success: true, data: { path: 'x' } }) });
app._reloadFileBrowserKeepingQuery = vi.fn(async () => {});
const path = "src/x'-alert(1)-' & #?.txt";
await app.deleteFileFromBrowser(SESSION, path);
expect(context.fetch).toHaveBeenCalledTimes(1);
expect(context.fetch).toHaveBeenCalledWith(`/api/sessions/${SESSION}/files?path=${encodeURIComponent(path)}`, {
method: 'DELETE',
});
expect(app.showToast).toHaveBeenCalledWith(`File deleted: ${path}`, 'success');
expect(app._reloadFileBrowserKeepingQuery).toHaveBeenCalledTimes(1);
});
it('shows the server reason and reloads nothing when the delete is refused', async () => {
const { app } = loadPanel({
fetch: async () => reply({ success: false, error: 'Symbolic links are not deleted from the Files panel' }, 400),
});
app._reloadFileBrowserKeepingQuery = vi.fn(async () => {});
await app.deleteFileFromBrowser(SESSION, 'AGENTS.md');
expect(app.showToast).toHaveBeenCalledWith(
'Delete failed: Symbolic links are not deleted from the Files panel',
'error'
);
expect(app._reloadFileBrowserKeepingQuery).not.toHaveBeenCalled();
});
it('removes the row from the search results it was deleted from', async () => {
let files = ['delete-me.txt', 'keep-me.txt', 'other.md'];
const { app, context } = loadPanel({
fetch: async (url, init) => {
const query = new URL(url, 'http://localhost');
if (init?.method === 'DELETE') {
const gone = query.searchParams.get('path')!.replace(/^src\//, '');
files = files.filter((name) => name !== gone);
return reply({ success: true, data: { path: query.searchParams.get('path') } });
}
const q = query.searchParams.get('q');
return reply(q ? searchEnvelope(files.filter((name) => name.includes(q))) : treeEnvelope(files));
},
});
await app.loadFileBrowser(SESSION);
(document.getElementById('fileBrowserSearch') as HTMLInputElement).value = 'me';
app.filterFileBrowser('me');
await vi.waitFor(() => expect(rowFor('src/delete-me.txt')).toBeTruthy());
expect(rowFor('src/other.md')).toBeUndefined();
deleteButtonFor('src/delete-me.txt')!.click();
await vi.waitFor(() => expect(rowFor('src/delete-me.txt')).toBeUndefined());
expect(rowFor('src/keep-me.txt')).toBeTruthy();
// Still the search view, not the tree behind it.
expect(rowFor('src/other.md')).toBeUndefined();
expect(document.getElementById('fileBrowserStatus')!.textContent).toBe('1 match');
const methods = context.fetch.mock.calls.map(([url, init]: [string, { method?: string }?]) =>
init?.method === 'DELETE' ? 'DELETE' : url.includes('&q=') ? 'search' : 'tree'
);
expect(methods).toEqual(['tree', 'search', 'DELETE', 'tree', 'search']);
});
});
describe('Files panel delete button: touch screens', () => {
it('keeps the row buttons visible where there is no hover', () => {
const blocks = [...STYLES.matchAll(/@media \(hover: none\) \{([^{}]*\{[^{}]*\})\s*\}/g)].map((m) => m[1]);
expect(
blocks.some((block) => /\.file-tree-download,\s*\.file-tree-delete\s*\{\s*display: inline;/.test(block))
).toBe(true);
});
});
+141
View File
@@ -0,0 +1,141 @@
// Port: none (the real home-screen modules in a vm context with a fake
// matchMedia; no browser, no server).
//
// #702: folding a phone on the home screen must swap the home surface even when
// the page loaded wide. The phone overview's breakpoint listener used to be
// wired from its first show, which a wide-loaded page never reaches. Both home
// screens' listeners are now installed once at startup and self-gate on a home
// screen being up. The real resize walk lives in
// test/home-breakpoint-swap.browser.test.ts (browser suite, not in the gate);
// this file keeps the wiring itself under `npm test`.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
type Listener = () => void;
function loadHomeApp(innerWidth = 1440) {
const listeners = new Map<string, Listener[]>();
const elements: Record<string, any> = {
welcomeOverlay: { classList: new Set<string>(), hidden: false },
mobileOverview: { classList: new Set<string>(), hidden: true },
homeSessions: { hidden: true },
};
for (const el of Object.values(elements)) {
if (el.classList) {
const set = el.classList as Set<string>;
el.classList = {
contains: (c: string) => set.has(c),
add: (c: string) => set.add(c),
remove: (c: string) => set.delete(c),
};
}
}
const timers: Array<() => void> = [];
const window: any = {
innerWidth,
matchMedia: (query: string) => ({
matches: false,
addEventListener: (_type: string, fn: Listener) => {
listeners.set(query, [...(listeners.get(query) || []), fn]);
},
}),
};
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
console,
window,
document: {
documentElement: { getAttribute: () => null },
getElementById: (id: string) => elements[id] || null,
createElement: () => ({ dataset: {}, style: {}, appendChild() {}, setAttribute() {} }),
},
MobileDetection: { getDeviceType: () => (window.innerWidth < 600 ? 'mobile' : 'desktop') },
});
for (const file of ['constants.js', 'mobile-overview.js', 'home-sessions.js']) {
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
}
const app = new (CodemanApp as any)();
app.loadAppSettingsFromStorage = () => ({});
app.activeSessionId = null;
app.showWelcomeCalls = 0;
app.showWelcome = () => app.showWelcomeCalls++;
app.renderHomeSessionsCalls = 0;
app.renderHomeSessions = () => {
app.renderHomeSessionsCalls++;
elements.homeSessions.hidden = false;
};
app._debouncedCall = (_key: string, fn: () => void) => timers.push(fn);
const fire = (query: string) => (listeners.get(query) || []).forEach((fn) => fn());
const flushTimers = () => timers.splice(0).forEach((fn) => fn());
return { app, window, elements, listeners, fire, flushTimers };
}
const PHONE_QUERY = '(max-width: 599px)';
const RAIL_QUERY = '(min-width: 1180px)';
describe('home-screen breakpoint listeners (#702)', () => {
it('installs each listener once, with nothing shown yet', () => {
const { app, listeners } = loadHomeApp();
app._wireMobileOverviewBreakpoint();
app._wireHomeSessionsBreakpoint();
// The lazy wiring from a first show must not add a second copy.
app._wireMobileOverviewBreakpoint();
app._wireHomeSessionsBreakpoint();
expect(listeners.get(PHONE_QUERY)).toHaveLength(1);
expect(listeners.get(RAIL_QUERY)).toHaveLength(1);
});
it('a fold on a wide-loaded welcome screen re-decides the surface', () => {
const { app, window, elements, fire } = loadHomeApp(1440);
app._wireMobileOverviewBreakpoint();
elements.welcomeOverlay.classList.add('visible');
window.innerWidth = 390;
fire(PHONE_QUERY);
expect(app.showWelcomeCalls).toBe(1);
});
it('an unfold on the phone overview re-decides the surface', () => {
const { app, window, elements, fire } = loadHomeApp(390);
app._wireMobileOverviewBreakpoint();
elements.mobileOverview.classList.add('visible');
window.innerWidth = 820;
fire(PHONE_QUERY);
expect(app.showWelcomeCalls).toBe(1);
});
it('stays out of the way inside a session and before any home screen is up', () => {
const { app, elements, fire } = loadHomeApp();
app._wireMobileOverviewBreakpoint();
fire(PHONE_QUERY); // page still loading: no home surface yet
elements.welcomeOverlay.classList.add('visible');
app.activeSessionId = 'abc';
fire(PHONE_QUERY);
expect(app.showWelcomeCalls).toBe(0);
});
it('a rail render queued before a fold does not bring the hidden rail back', () => {
const { app, window, elements, flushTimers } = loadHomeApp(1440);
elements.welcomeOverlay.classList.add('visible');
elements.homeSessions.hidden = false;
app._refreshHomeSessionsIfVisible(); // queued while the rail is still up
window.innerWidth = 390; // the fold
elements.homeSessions.hidden = true; // showWelcome()'s phone path hid it
flushTimers();
expect(app.renderHomeSessionsCalls).toBe(0);
expect(elements.homeSessions.hidden).toBe(true);
});
it('is wired from init(), not from the first show', () => {
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const start = appJs.indexOf('\n init() {');
const end = appJs.indexOf('\n }\n', start);
const init = appJs.slice(start, end);
expect(init).toContain('this._wireMobileOverviewBreakpoint?.()');
expect(init).toContain('this._wireHomeSessionsBreakpoint?.()');
});
});
+151
View File
@@ -0,0 +1,151 @@
/**
* @fileoverview Folding or unfolding a phone on the home screen swaps the home
* surface in place, whichever width the page loaded at (#702).
*
* Both breakpoint listeners (the phone overview's `(max-width: 599px)` and the
* desktop rail's `(min-width: 1180px)`) used to be wired from the first show of
* their own surface. A page that loaded wide never showed the phone overview,
* so folding it to phone width left the welcome screen up until something
* called `showWelcome()` again. They are now installed once at startup.
*
* Real server, real Chromium, one page per direction, resized across 599/600
* and 767/768 in both directions with no navigation, plus a height-only resize
* (the on-screen keyboard) that must not re-decide the surface.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
type Surface = { welcome: boolean; overview: boolean; rail: boolean };
describe('home surface follows a fold or unfold without a reload', () => {
let server: WebServer;
let browser: Browser;
let workDir: string;
beforeAll(async () => {
server = new WebServer(0, false, true);
await server.start();
// One session, so the desktop rail has a row to show (it hides when empty).
workDir = mkdtempSync(join(tmpdir(), 'home-breakpoint-'));
const res = await fetch(`http://localhost:${server.boundPort}/api/sessions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ workingDir: workDir }),
});
expect(res.ok).toBe(true);
browser = await chromium.launch({ headless: true });
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
if (workDir) rmSync(workDir, { recursive: true, force: true });
}, 60000);
async function open(width: number, height: number): Promise<[BrowserContext, Page]> {
const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 1 });
const page = await context.newPage();
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
// The load selects the one session; Home is where a fold has to swap surfaces.
await page.waitForFunction(() => (window as any).app?.sessions?.size > 0, null, { timeout: 30000 });
await page.evaluate(() => (window as any).app.goHome());
await page.waitForFunction(
() =>
document.getElementById('welcomeOverlay')?.classList.contains('visible') ||
document.getElementById('mobileOverview')?.classList.contains('visible'),
null,
{ timeout: 30000 }
);
return [context, page];
}
const surface = (page: Page): Promise<Surface> =>
page.evaluate(() => {
const overview = document.getElementById('mobileOverview')!;
const rail = document.getElementById('homeSessions')!;
return {
welcome: document.getElementById('welcomeOverlay')!.classList.contains('visible'),
overview: !overview.hidden && overview.classList.contains('visible'),
rail: !rail.hidden,
};
});
/** Resize, then wait until the surface settles on what that width should show. */
async function resizeTo(page: Page, width: number, height: number, expected: Surface) {
await page.setViewportSize({ width, height });
await expect
.poll(() => surface(page), { timeout: 5000, message: `surface at ${width}x${height}` })
.toEqual(expected);
}
const PHONE: Surface = { welcome: false, overview: true, rail: false };
const TABLET: Surface = { welcome: true, overview: false, rail: false };
const DESKTOP: Surface = { welcome: true, overview: false, rail: true };
it('loaded wide: folding to phone width shows the phone overview, unfolding brings the welcome back', async () => {
const [context, page] = await open(1440, 900);
try {
expect(await surface(page)).toEqual(DESKTOP);
await resizeTo(page, 768, 900, TABLET);
await resizeTo(page, 767, 900, TABLET);
await resizeTo(page, 600, 900, TABLET);
await resizeTo(page, 599, 900, PHONE);
await resizeTo(page, 390, 844, PHONE);
await resizeTo(page, 600, 900, TABLET);
await resizeTo(page, 767, 900, TABLET);
await resizeTo(page, 768, 900, TABLET);
await resizeTo(page, 1440, 900, DESKTOP);
// A straight fold, the way a real device changes posture, in one step.
await resizeTo(page, 390, 844, PHONE);
} finally {
await context.close();
}
}, 60000);
it('loaded narrow: unfolding to desktop width shows the welcome and the rail, folding brings the overview back', async () => {
const [context, page] = await open(390, 844);
try {
expect(await surface(page)).toEqual(PHONE);
await resizeTo(page, 599, 844, PHONE);
await resizeTo(page, 600, 844, TABLET);
await resizeTo(page, 767, 844, TABLET);
await resizeTo(page, 768, 844, TABLET);
await resizeTo(page, 1440, 900, DESKTOP);
await resizeTo(page, 768, 900, TABLET);
await resizeTo(page, 767, 900, TABLET);
await resizeTo(page, 600, 900, TABLET);
await resizeTo(page, 599, 900, PHONE);
} finally {
await context.close();
}
}, 60000);
it('a height-only resize (the on-screen keyboard) does not re-decide the home surface', async () => {
const [context, page] = await open(1440, 900);
try {
await resizeTo(page, 390, 844, PHONE);
await page.evaluate(() => {
const app = (window as any).app;
app.__showWelcomeCalls = 0;
const original = app.showWelcome;
app.showWelcome = function (...args: unknown[]) {
app.__showWelcomeCalls++;
return original.apply(this, args);
};
});
await page.setViewportSize({ width: 390, height: 480 });
await page.waitForTimeout(400);
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(400);
expect(await surface(page)).toEqual(PHONE);
expect(await page.evaluate(() => (window as any).app.__showWelcomeCalls)).toBe(0);
} finally {
await context.close();
}
}, 60000);
});
+59
View File
@@ -0,0 +1,59 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser } from 'playwright';
import { WebServer } from '../src/web/server.js';
const id = 'log-reconnect-fixture',
path = '/workspace/demo.log';
describe('log viewer pre-connect failures', () => {
let server: WebServer, browser: Browser;
beforeAll(async () => {
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
}, 60000);
afterAll(async () => {
await browser?.close();
await server?.stop();
});
it.each([
'Path must be within working directory or allowed log directories',
'File does not exist',
'File not found or not accessible',
'File too large',
'Maximum 5 concurrent streams per session',
'Failed to spawn tail process',
])('does not reconnect after %s before connected', async (error) => {
const page = await browser.newPage(),
errors: string[] = [];
let requests = 0;
page.on('pageerror', (e) => errors.push(e.message));
await page.route(`**/api/sessions/${id}/tail-file?**`, async (route) => {
requests++;
return route.fulfill({
status: 200,
contentType: 'text/event-stream',
// retry: 200 makes a reconnect, if one came, show up within 200 ms.
body: `retry: 200\ndata: ${JSON.stringify({ type: 'error', error })}\n\n`,
});
});
try {
await page.goto(`http://127.0.0.1:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.openLogViewerWindow);
await page.waitForTimeout(3500);
await page.evaluate(
({ id, path }) => {
const app = (window as any).app;
app.sessions.set(id, { id, mode: 'shell', workingDir: '/workspace' });
app.openLogViewerWindow(path, id);
},
{ id, path }
);
await expect.poll(() => page.locator('.log-viewer-window').innerText()).toContain(error);
await page.waitForTimeout(1000);
expect(requests).toBe(1);
expect(errors).toEqual([]);
} finally {
await page.close();
}
});
});
+309
View File
@@ -0,0 +1,309 @@
/**
* @fileoverview Zoom in the path picker's image preview (salvaged from #608): pinch, one-finger pan,
* double-click, wheel and the out / fit / in buttons. Same jsdom harness as
* path-picker-hidden.test.ts (see its header for why the window is built by hand).
*
* These drive the real listeners and read the zoom state back, because what can go wrong is
* geometric, not textual:
*
* 1. the point under the cursor or between the fingers drifting while the scale changes,
* 2. a pan that carries the image out of view (a small image is drawn at its own size with
* `object-fit: scale-down`, so the image box is not the image),
* 3. a trackpad's stream of small wheel deltas jumping to the maximum, as 20% per event did,
* 4. controls left on screen for an image that failed to load,
* 5. listeners or zoom state outliving the preview, or a late `load`/`error` from a replaced
* preview acting on the current one.
*
* jsdom lays nothing out and loads no images, so the viewport's rect and the image's natural size
* are stubbed per test and `load`/`error` are dispatched by hand.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8');
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
const doc = jsdomWindow.document;
/** The preview body's box: 400x300 at the page origin, so its centre is (200, 150). */
const BOX = { left: 0, top: 0, width: 400, height: 300 };
const CENTRE = { x: BOX.left + BOX.width / 2, y: BOX.top + BOX.height / 2 };
type Point = { x: number; y: number };
type ZoomState = { scale: number; x: number; y: number };
function loadPathPicker(): any {
const factory = new Function(
'window',
'document',
'localStorage',
'fetch',
'MobileDetection',
`${accessoryJs}\nreturn PathPicker;`
);
return factory(jsdomWindow, doc, jsdomWindow.localStorage, vi.fn(), { isTouchDevice: () => false });
}
describe('PathPicker image preview zoom', () => {
let PathPicker: any;
beforeEach(() => {
doc.body.replaceChildren();
PathPicker = loadPathPicker();
});
afterEach(() => {
PathPicker?.closePreview?.(false);
doc.body.replaceChildren();
});
/** Open an image preview whose image has the given natural size (800x600 fills the box exactly). */
const openImage = (natural = { width: 800, height: 600 }, name = 'shot.png') => {
PathPicker.openPreview({ name, path: `/home/dev/${name}`, previewKind: 'image' });
const overlay = PathPicker._previewOverlay as HTMLElement;
const body = overlay.querySelector('.path-preview-body') as HTMLElement;
body.getBoundingClientRect = () =>
({
...BOX,
x: BOX.left,
y: BOX.top,
right: BOX.left + BOX.width,
bottom: BOX.top + BOX.height,
toJSON: () => ({}),
}) as DOMRect;
const image = body.querySelector('img.path-preview-image') as HTMLImageElement;
Object.defineProperty(image, 'naturalWidth', { value: natural.width, configurable: true });
Object.defineProperty(image, 'naturalHeight', { value: natural.height, configurable: true });
const controls = overlay.querySelector('.path-preview-zoom') as HTMLElement;
const button = (zoom: string) => controls.querySelector(`[data-zoom="${zoom}"]`) as HTMLButtonElement;
return { overlay, body, image, controls, button };
};
const fire = (target: EventTarget, type: string) => target.dispatchEvent(new jsdomWindow.Event(type));
const state = (): ZoomState => PathPicker._previewImageZoom;
/** Where, in the unzoomed image, the given screen point lands under the current transform. */
const imagePointUnder = (p: Point, s: ZoomState = state()): Point => ({
x: (p.x - CENTRE.x - s.x) / s.scale,
y: (p.y - CENTRE.y - s.y) / s.scale,
});
const wheel = (target: EventTarget, init: Record<string, unknown>) => {
const event = new jsdomWindow.WheelEvent('wheel', { bubbles: true, cancelable: true, ...init });
target.dispatchEvent(event);
return event;
};
/** jsdom has no PointerEvent: a MouseEvent of the pointer type with a pointerId behaves the same. */
const pointer = (target: EventTarget, type: string, id: number, p: Point) => {
const event = new jsdomWindow.MouseEvent(type, { bubbles: true, cancelable: true, clientX: p.x, clientY: p.y });
Object.defineProperty(event, 'pointerId', { value: id });
target.dispatchEvent(event);
};
const dblclick = (target: EventTarget, p: Point) =>
target.dispatchEvent(
new jsdomWindow.MouseEvent('dblclick', { bubbles: true, cancelable: true, clientX: p.x, clientY: p.y })
);
it('shows the controls only once the image has loaded, and the buttons step the zoom', () => {
const { image, controls, button } = openImage();
expect(controls.hidden).toBe(true);
fire(image, 'load');
expect(controls.hidden).toBe(false);
expect(button('fit').textContent).toBe('100%');
button('in').click();
expect(state().scale).toBeCloseTo(1.5);
expect(button('fit').textContent).toBe('150%');
expect(image.classList.contains('is-zoomed')).toBe(true);
button('fit').click();
expect(state()).toMatchObject({ scale: 1 });
expect(Math.abs(state().x) + Math.abs(state().y)).toBe(0);
expect(button('fit').textContent).toBe('100%');
expect(image.classList.contains('is-zoomed')).toBe(false);
button('out').click();
expect(state().scale).toBe(1);
});
it('wheel zoom keeps the image point under the cursor where it was', () => {
const { body, image } = openImage();
fire(image, 'load');
for (const cursor of [
{ x: 300, y: 100 },
{ x: 120, y: 220 },
{ x: 390, y: 290 },
]) {
const before = imagePointUnder(cursor);
const scaleBefore = state().scale;
wheel(body, { deltaY: -100, clientX: cursor.x, clientY: cursor.y });
expect(state().scale).toBeCloseTo(scaleBefore * 2 ** 0.2);
const after = imagePointUnder(cursor);
expect(after.x).toBeCloseTo(before.x);
expect(after.y).toBeCloseTo(before.y);
}
expect(image.style.transform).toContain(`scale(${state().scale})`);
});
it('pinch zooms by the finger distance around the pinch centre', () => {
const { image } = openImage();
fire(image, 'load');
const centre = { x: 150, y: 150 };
const before = imagePointUnder(centre);
pointer(image, 'pointerdown', 1, { x: 100, y: 150 });
pointer(image, 'pointerdown', 2, { x: 200, y: 150 });
pointer(image, 'pointermove', 2, { x: 300, y: 150 });
expect(state().scale).toBeCloseTo(2);
const after = imagePointUnder(centre);
expect(after.x).toBeCloseTo(before.x);
expect(after.y).toBeCloseTo(before.y);
pointer(image, 'pointerup', 2, { x: 300, y: 150 });
pointer(image, 'pointerup', 1, { x: 100, y: 150 });
expect(state().pointers.size).toBe(0);
});
it('pans a zoomed image only until its edge meets the viewport edge', () => {
const { image } = openImage();
fire(image, 'load');
dblclick(image, CENTRE);
expect(state().scale).toBe(2);
pointer(image, 'pointerdown', 1, CENTRE);
pointer(image, 'pointermove', 1, { x: 5000, y: 5000 });
// 800x600 drawn at 400x300, zoomed 2x to 800x600 in a 400x300 box: 200 and 150 of slack.
expect(state()).toMatchObject({ x: 200, y: 150 });
pointer(image, 'pointermove', 1, { x: -5000, y: -5000 });
expect(state()).toMatchObject({ x: -200, y: -150 });
pointer(image, 'pointerup', 1, { x: -5000, y: -5000 });
// Without zoom there is nothing to pan.
dblclick(image, CENTRE);
pointer(image, 'pointerdown', 1, CENTRE);
pointer(image, 'pointermove', 1, { x: 300, y: 250 });
expect(state()).toMatchObject({ scale: 1 });
expect(Math.abs(state().x) + Math.abs(state().y)).toBe(0);
});
it('keeps a small image in view: centred while it is smaller than the viewport, clamped to its own edges after', () => {
// 100x50 is drawn at its own size (scale-down), not stretched to the 400x300 box.
const { image } = openImage({ width: 100, height: 50 });
fire(image, 'load');
dblclick(image, { x: 380, y: 280 });
expect(state().scale).toBe(2);
pointer(image, 'pointerdown', 1, CENTRE);
pointer(image, 'pointermove', 1, { x: 5000, y: 5000 });
// 200x100 still fits inside 400x300 on both axes: it stays centred.
expect(Math.abs(state().x) + Math.abs(state().y)).toBe(0);
pointer(image, 'pointerup', 1, { x: 5000, y: 5000 });
PathPicker._setPreviewImageZoom(6);
pointer(image, 'pointerdown', 1, CENTRE);
pointer(image, 'pointermove', 1, { x: 5000, y: 5000 });
// 600x300 at 6x: 100 of horizontal slack, none vertically. The image box (400x300 at 6x) would
// have allowed 1000 and 750, carrying the image entirely out of the viewport.
expect(state().x).toBe(100);
expect(Math.abs(state().y)).toBe(0);
});
it('scales wheel zoom by deltaY, ignores a delta of 0 and caps one event', () => {
const factor = (init: Record<string, unknown>) => PathPicker._previewWheelZoomFactor(init);
expect(factor({ deltaY: 0, deltaX: 40 })).toBe(1);
expect(factor({ deltaY: -4 })).toBeCloseTo(2 ** 0.008);
expect(factor({ deltaY: -100 })).toBeCloseTo(2 ** 0.2);
expect(factor({ deltaY: 100 })).toBeCloseTo(2 ** -0.2);
// Lines (Firefox's mouse wheel) and ctrl+wheel (a trackpad pinch) are scaled up, not ignored.
expect(factor({ deltaY: -3, deltaMode: 1 })).toBeCloseTo(2 ** 0.15);
expect(factor({ deltaY: -10, ctrlKey: true })).toBeCloseTo(2 ** 0.2);
expect(factor({ deltaY: -100000 })).toBeCloseTo(Math.SQRT2);
expect(factor({ deltaY: 100000 })).toBeCloseTo(Math.SQRT1_2);
const { body, image } = openImage();
fire(image, 'load');
const zoom = vi.spyOn(PathPicker, '_setPreviewImageZoom');
const sideways = wheel(body, { deltaY: 0, deltaX: 40, clientX: 300, clientY: 100 });
expect(sideways.defaultPrevented).toBe(true);
expect(zoom).not.toHaveBeenCalled();
expect(state().scale).toBe(1);
// A trackpad scroll arrives as many small deltas; at 20% per event these 30 hit the 600% cap.
for (let i = 0; i < 30; i++) wheel(body, { deltaY: -4, clientX: 300, clientY: 100 });
expect(state().scale).toBeCloseTo(2 ** 0.24);
expect(state().scale).toBeLessThan(1.25);
});
it('hides the controls and stops zooming when the image fails to load', () => {
const { body, image, controls } = openImage();
fire(image, 'error');
expect(controls.hidden).toBe(true);
expect(body.querySelector('.path-preview-error')?.textContent).toBe('Image preview failed to load');
expect(PathPicker._previewImageZoom).toBeNull();
const zoom = vi.spyOn(PathPicker, '_setPreviewImageZoom');
wheel(body, { deltaY: -100, clientX: 200, clientY: 150 });
expect(zoom).not.toHaveBeenCalled();
});
it('closing drops the zoom state and every listener, and the next image starts at 100%', () => {
const first = openImage();
fire(first.image, 'load');
dblclick(first.image, { x: 300, y: 100 });
expect(state().scale).toBe(2);
const transformAtClose = first.image.style.transform;
PathPicker.closePreview(false);
expect(PathPicker._previewImageZoom).toBeNull();
expect(PathPicker._previewImageCleanup).toBeNull();
expect(first.overlay.isConnected).toBe(false);
const zoom = vi.spyOn(PathPicker, '_setPreviewImageZoom');
wheel(first.body, { deltaY: -100, clientX: 200, clientY: 150 });
dblclick(first.image, CENTRE);
first.button('in').click();
expect(zoom).not.toHaveBeenCalled();
expect(first.image.style.transform).toBe(transformAtClose);
zoom.mockRestore();
const second = openImage({ width: 800, height: 600 }, 'next.png');
fire(second.image, 'load');
expect(state()).toMatchObject({ scale: 1 });
expect(second.button('fit').textContent).toBe('100%');
expect(second.image.classList.contains('is-zoomed')).toBe(false);
});
it('a late load or error from a replaced preview leaves the current one alone', () => {
const stale = openImage({ width: 800, height: 600 }, 'old.png');
const current = openImage({ width: 800, height: 600 }, 'new.png');
fire(current.image, 'load');
dblclick(current.image, { x: 300, y: 100 });
const zoomed = { ...state() };
fire(stale.image, 'load');
expect(state()).toMatchObject({ scale: zoomed.scale, x: zoomed.x, y: zoomed.y });
fire(stale.image, 'error');
expect(current.body.querySelector('.path-preview-error')).toBeNull();
expect(current.image.isConnected).toBe(true);
expect(current.controls.hidden).toBe(false);
expect(PathPicker._previewImageZoom).not.toBeNull();
});
it('draws a small image at its own size rather than stretching it (scale-down)', () => {
const rule = stylesCss.match(/\n\.path-preview-image \{([^}]*)\}/);
expect(rule?.[1]).toMatch(/object-fit:\s*scale-down;/);
expect(rule?.[1]).not.toMatch(/object-fit:\s*contain;/);
});
});
+34 -7
View File
@@ -302,7 +302,13 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
const seeded = {
caseOrder: ['a', 'proj', 'b'],
lastUsedCase: 'proj',
lastPickedCase: 'proj',
caseSettings: { proj: { agentTeams: true, opusContext1m: false } },
};
writeFileSync(SETTINGS_PATH, JSON.stringify(seeded));
// the registry reads fine but cannot be written: the rename must undo the folder move
const spy = failRegistryWrite();
let res;
@@ -316,7 +322,7 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
expect(linked()).toEqual({ proj: real });
expect(existsSync(join(work, 'renamed'))).toBe(false);
// settings.json is written first, so it must be put back too
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' });
expect(settings()).toEqual(seeded);
});
it('puts back only the keys it wrote, keeping a settings write made in between', async () => {
@@ -324,17 +330,38 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
// Another writer (a settings save) lands after the rename wrote settings.json and before the
// registry write fails.
const spy = failRegistryWrite(() => mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true })));
const entry = { agentTeams: true, opusContext1m: false };
writeFileSync(
SETTINGS_PATH,
JSON.stringify({
caseOrder: ['a', 'proj', 'b'],
lastUsedCase: 'proj',
lastPickedCase: 'proj',
caseSettings: { proj: entry },
})
);
// Another writer (a settings save, and a per-case save from another device) lands after the rename wrote
// settings.json and before the registry write fails.
const spy = failRegistryWrite(() =>
mutateSettingsFile((current) => ({
...current,
agentSkillEnabled: true,
caseSettings: { ...(current.caseSettings as object), other: { agentTeams: false, opusContext1m: true } },
}))
);
try {
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(500);
} finally {
spy.mockRestore();
}
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj', agentSkillEnabled: true });
expect(settings()).toEqual({
caseOrder: ['a', 'proj', 'b'],
lastUsedCase: 'proj',
lastPickedCase: 'proj',
caseSettings: { proj: entry, other: { agentTeams: false, opusContext1m: true } },
agentSkillEnabled: true,
});
});
describe('when the registry changes after the first check', () => {
+322
View File
@@ -0,0 +1,322 @@
/**
* @fileoverview `DELETE /api/sessions/:id/files?path=`, the Files panel's delete button (salvaged
* from #529).
*
* Real temp workspace, no fs mock, like test/routes/file-write-routes.test.ts: the thing under
* test is what the route does to real files, and the defect it fixes (a delete of
* `AGENTS.md -> CLAUDE.md` removed CLAUDE.md, because the confinement check hands back the
* realpath) only shows against a real symlink. Each refusal also checks that nothing on disk moved.
*
* Uses app.inject() — no real HTTP ports needed.
* Port: N/A (app.inject doesn't open ports)
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
describe('DELETE /api/sessions/:id/files (real fs)', () => {
let harness: RouteTestHarness;
let base: string;
let workDir: string;
let outsideDir: string;
const del = (path: string, h: RouteTestHarness = harness) =>
h.app.inject({
method: 'DELETE',
url: `/api/sessions/${h.ctx._sessionId}/files?path=${encodeURIComponent(path)}`,
});
beforeEach(async () => {
harness = await createRouteTestHarness(registerFileRoutes);
// realpath: tmpdir() can hold a symlinked component (macOS /tmp), and the fixtures compare paths.
base = realpathSync(mkdtempSync(join(tmpdir(), 'codeman-delete-')));
workDir = join(base, 'ws');
outsideDir = join(base, 'outside');
mkdirSync(workDir);
mkdirSync(outsideDir);
harness.ctx._session.workingDir = workDir;
});
afterEach(async () => {
await harness.app.close();
rmSync(base, { recursive: true, force: true });
rmSync(SETTINGS_PATH, { force: true });
});
it('deletes a regular file and answers the path it was given', async () => {
mkdirSync(join(workDir, 'src'));
writeFileSync(join(workDir, 'src', 'old.ts'), 'x');
writeFileSync(join(workDir, 'keep.ts'), 'y');
const res = await del('src/old.ts');
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ success: true, data: { path: 'src/old.ts' } });
expect(existsSync(join(workDir, 'src', 'old.ts'))).toBe(false);
expect(existsSync(join(workDir, 'src'))).toBe(true);
expect(readFileSync(join(workDir, 'keep.ts'), 'utf8')).toBe('y');
});
it('deletes a file whose name carries quotes and other characters a shell or a URL cares about', async () => {
const name = "x'-alert(1)-' & #?.txt";
writeFileSync(join(workDir, name), 'x');
const res = await del(name);
expect(res.statusCode).toBe(200);
expect(existsSync(join(workDir, name))).toBe(false);
});
describe('symbolic links are refused, never followed', () => {
it('leaves both the link and its target when asked to delete AGENTS.md -> CLAUDE.md', async () => {
writeFileSync(join(workDir, 'CLAUDE.md'), '# rules');
symlinkSync('CLAUDE.md', join(workDir, 'AGENTS.md'));
const res = await del('AGENTS.md');
expect(res.statusCode).toBe(400);
expect(res.json().success).toBe(false);
expect(res.json().error).toMatch(/symbolic link/i);
expect(readFileSync(join(workDir, 'CLAUDE.md'), 'utf8')).toBe('# rules');
expect(lstatSync(join(workDir, 'AGENTS.md')).isSymbolicLink()).toBe(true);
});
it('refuses a link to a folder, which the listing shows as a file row', async () => {
mkdirSync(join(workDir, 'real'));
writeFileSync(join(workDir, 'real', 'a.txt'), 'a');
symlinkSync('real', join(workDir, 'alias'));
const res = await del('alias');
expect(res.statusCode).toBe(400);
expect(readFileSync(join(workDir, 'real', 'a.txt'), 'utf8')).toBe('a');
expect(lstatSync(join(workDir, 'alias')).isSymbolicLink()).toBe(true);
});
it('answers 404 for a link that leads out of the workspace, and leaves its target', async () => {
writeFileSync(join(outsideDir, 'secret.txt'), 'secret');
symlinkSync(join(outsideDir, 'secret.txt'), join(workDir, 'leak.txt'));
const res = await del('leak.txt');
expect(res.statusCode).toBe(404);
expect(readFileSync(join(outsideDir, 'secret.txt'), 'utf8')).toBe('secret');
});
it('deletes a plain file reached through a linked folder inside the workspace', async () => {
mkdirSync(join(workDir, 'real'));
writeFileSync(join(workDir, 'real', 'b.txt'), 'b');
symlinkSync('real', join(workDir, 'alias'));
const res = await del('alias/b.txt');
expect(res.statusCode).toBe(200);
expect(existsSync(join(workDir, 'real', 'b.txt'))).toBe(false);
expect(lstatSync(join(workDir, 'alias')).isSymbolicLink()).toBe(true);
});
});
it('refuses a file under .git with 403, also when .git is reached through a linked folder', async () => {
mkdirSync(join(workDir, '.git'));
writeFileSync(join(workDir, '.git', 'config'), '[core]');
symlinkSync('.git', join(workDir, 'gitdir'));
for (const path of ['.git/config', 'gitdir/config']) {
const res = await del(path);
expect(res.statusCode).toBe(403);
expect(res.json().success).toBe(false);
}
expect(readFileSync(join(workDir, '.git', 'config'), 'utf8')).toBe('[core]');
});
it('refuses a sensitive file with 403', async () => {
writeFileSync(join(workDir, '.env'), 'TOKEN=abc');
const res = await del('.env');
expect(res.statusCode).toBe(403);
expect(readFileSync(join(workDir, '.env'), 'utf8')).toBe('TOKEN=abc');
});
it('answers 404 for a path that climbs out of the workspace', async () => {
writeFileSync(join(outsideDir, 'victim.txt'), 'v');
for (const path of ['../outside/victim.txt', join(outsideDir, 'victim.txt')]) {
const res = await del(path);
expect(res.statusCode).toBe(404);
}
expect(readFileSync(join(outsideDir, 'victim.txt'), 'utf8')).toBe('v');
});
it('answers 404 for a file that does not exist', async () => {
expect((await del('nope.txt')).statusCode).toBe(404);
});
it('refuses a directory with 400', async () => {
mkdirSync(join(workDir, 'src'));
writeFileSync(join(workDir, 'src', 'a.ts'), 'a');
const res = await del('src');
expect(res.statusCode).toBe(400);
expect(readFileSync(join(workDir, 'src', 'a.ts'), 'utf8')).toBe('a');
});
it('refuses a missing or repeated path parameter with 400', async () => {
const missing = await harness.app.inject({
method: 'DELETE',
url: `/api/sessions/${harness.ctx._sessionId}/files`,
});
expect(missing.statusCode).toBe(400);
writeFileSync(join(workDir, 'a.txt'), 'a');
const repeated = await harness.app.inject({
method: 'DELETE',
url: `/api/sessions/${harness.ctx._sessionId}/files?path=a.txt&path=a.txt`,
});
expect(repeated.statusCode).toBe(400);
expect(existsSync(join(workDir, 'a.txt'))).toBe(true);
});
it('refuses a remote (SSH) session before looking at the local twin of its folder', async () => {
// The local folder of the same absolute name is exactly the hazard: validation would resolve
// against it and the delete would land here while the user meant the remote file.
writeFileSync(join(workDir, 'twin.txt'), 'local');
harness.ctx._session.remote = {
hostId: 'host-1',
label: 'testhost',
host: '192.0.2.10',
username: 'j',
remotePath: workDir,
};
const res = await del('twin.txt');
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/remote/i);
expect(readFileSync(join(workDir, 'twin.txt'), 'utf8')).toBe('local');
});
it('refuses an adopted session, whose folder is not a workspace of ours', async () => {
writeFileSync(join(workDir, 'shared.txt'), 'tmp');
harness.ctx._session.isAdopted = true;
const res = await del('shared.txt');
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/adopted/i);
expect(readFileSync(join(workDir, 'shared.txt'), 'utf8')).toBe('tmp');
});
describe('a worktree outside the session folder', () => {
let sibling: string;
beforeEach(() => {
sibling = join(base, 'ws-feature');
mkdirSync(join(sibling, 'src'), { recursive: true });
writeFileSync(join(sibling, 'src', 'feature.ts'), 'feature');
Object.assign(harness.ctx._session, {
liveWorktree: { name: 'ws-feature', path: sibling, outside: true },
});
});
it('deletes an absolute path inside it, as the other file routes serve it', async () => {
const res = await del(join(sibling, 'src', 'feature.ts'));
expect(res.statusCode).toBe(200);
expect(existsSync(join(sibling, 'src', 'feature.ts'))).toBe(false);
});
it('does not reach it with a relative path, which resolves against the session folder only', async () => {
const res = await del('../ws-feature/src/feature.ts');
expect(res.statusCode).toBe(404);
expect(existsSync(join(sibling, 'src', 'feature.ts'))).toBe(true);
});
it('refuses a symlink in it that leads out of it, and leaves the target', async () => {
writeFileSync(join(outsideDir, 'key.txt'), 'secret');
symlinkSync(join(outsideDir, 'key.txt'), join(sibling, 'leak.txt'));
const res = await del(join(sibling, 'leak.txt'));
expect(res.statusCode).toBe(404);
expect(readFileSync(join(outsideDir, 'key.txt'), 'utf8')).toBe('secret');
});
it('reaches nothing there once it is no longer an outside worktree', async () => {
Object.assign(harness.ctx._session, { liveWorktree: { name: 'ws-feature', path: sibling } });
expect((await del(join(sibling, 'src', 'feature.ts'))).statusCode).toBe(404);
expect(existsSync(join(sibling, 'src', 'feature.ts'))).toBe(true);
});
it.each([
['workspace confinement is turned on', () => ({ attachmentConfineToWorkspace: true })],
['its parent folder is blocked', () => ({ attachmentBlockedPaths: [base] })],
])('deletes nothing there once %s', async (_label, settings) => {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify(settings()));
const res = await del(join(sibling, 'src', 'feature.ts'));
expect(res.statusCode).toBe(404);
expect(readFileSync(join(sibling, 'src', 'feature.ts'), 'utf8')).toBe('feature');
});
});
describe('multi-user mode', () => {
let prevMultiUser: string | undefined;
beforeEach(() => {
prevMultiUser = process.env.CODEMAN_MULTIUSER;
process.env.CODEMAN_MULTIUSER = '1';
});
afterEach(() => {
if (prevMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = prevMultiUser;
});
it("answers 404 for another user's session and deletes nothing", async () => {
const bob = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
try {
bob.ctx._session.workingDir = workDir;
(bob.ctx._session as unknown as { owner?: string }).owner = 'alice';
writeFileSync(join(workDir, 'alice.txt'), 'mine');
const res = await del('alice.txt', bob);
expect(res.statusCode).toBe(404);
expect(readFileSync(join(workDir, 'alice.txt'), 'utf8')).toBe('mine');
} finally {
await bob.app.close();
}
});
it('lets the owner delete in their own session', async () => {
const bob = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
try {
bob.ctx._session.workingDir = workDir;
(bob.ctx._session as unknown as { owner?: string }).owner = 'bob';
writeFileSync(join(workDir, 'bob.txt'), 'mine');
expect((await del('bob.txt', bob)).statusCode).toBe(200);
expect(existsSync(join(workDir, 'bob.txt'))).toBe(false);
} finally {
await bob.app.close();
}
});
});
});
+1 -1
View File
@@ -146,7 +146,7 @@ async function boot(who: Who, stored: Settings, server: Settings | null) {
};
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
var KeyboardAccessoryBar = { setMode() {} };
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var VoiceInput = { SILENCE_TIMEOUT_SECONDS: [0, 3, 6, 15, 30], _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
var DEFAULT_VOICE_KEYTERMS = '';`,
...SCRIPTS,
+5 -1
View File
@@ -159,7 +159,7 @@ async function boot(
};
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
var KeyboardAccessoryBar = { setMode() {} };
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var VoiceInput = { SILENCE_TIMEOUT_SECONDS: [0, 3, 6, 15, 30], _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
var DEFAULT_VOICE_KEYTERMS = '';`,
read('constants.js'),
@@ -526,6 +526,10 @@ describe('the new toasts in zh-CN', () => {
'Saved on this device, not on the server: the server could not be reached',
'已保存到本设备,但未保存到服务器:无法连接到服务器',
],
[
'Options for "demo" (until the next sync) saved on this device, not on the server: HTTP 500',
'案例“demo”的选项已保存到本设备(同步前有效),但未保存到服务器:HTTP 500',
],
[`Tunnel not stopped: ${TORN}`, `隧道未停止:${TORN}`],
];
for (const [english, chinese] of cases) {
+138
View File
@@ -0,0 +1,138 @@
/**
* @fileoverview Opt-in case sync, server side (live server, ephemeral port, real settings.json): the shared
* per-case options merge inside the serialised settings writer, a non-admin never reads them, and a case
* rename or delete carries them along (CASE-SYNC-001, CASE-SYNC-002).
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
import { getCasesDir } from '../src/config/cases-dir.js';
import { settingsForNonAdmin } from '../src/web/routes/system-routes.js';
import {
dropCaseSettings,
MAX_SHARED_CASES,
mergeCaseSettings,
renameCaseSettings,
} from '../src/web/case-state-sync.js';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
const on = { agentTeams: true, opusContext1m: false };
const off = { agentTeams: false, opusContext1m: true };
let server: WebServer;
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
const seed = (value: unknown) => writeFileSync(SETTINGS_PATH, JSON.stringify(value, null, 2));
const readSettings = () => JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8')) as Record<string, any>;
async function send(method: string, path: string, body?: unknown) {
const res = await fetch(url(path), {
method,
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: res.status, body: (await res.json()) as Record<string, any> };
}
let n = 0;
const caseName = () => `case-sync-${process.pid}-${++n}`;
beforeAll(async () => {
mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true });
seed({});
server = new WebServer(0, false, true);
await server.start();
}, 30000);
afterAll(async () => {
await server?.stop();
vi.restoreAllMocks();
}, 30000);
describe('shared case options', () => {
beforeEach(() => seed({ syncCaseState: true }));
it('merges concurrent saves of different cases instead of letting the second discard the first', async () => {
const [a, b, c] = await Promise.all([
send('PUT', '/api/settings', { caseSettings: { alpha: on } }),
send('PUT', '/api/settings', { caseSettings: { beta: off } }),
send('PUT', '/api/settings', { caseSettings: { gamma: on } }),
]);
expect([a.status, b.status, c.status]).toEqual([200, 200, 200]);
expect(readSettings().caseSettings).toEqual({ alpha: on, beta: off, gamma: on });
});
it('ignores a caseSettings PUT while syncCaseState is off, and takes one that turns it on', async () => {
seed({ syncCaseState: false, caseSettings: { kept: off } });
expect((await send('PUT', '/api/settings', { caseSettings: { alpha: on } })).status).toBe(200);
expect(readSettings().caseSettings).toEqual({ kept: off });
seed({});
await send('PUT', '/api/settings', { caseSettings: { alpha: on } });
expect(readSettings().caseSettings).toBeUndefined();
await send('PUT', '/api/settings', { syncCaseState: true, caseSettings: { alpha: on } });
expect(readSettings().caseSettings).toEqual({ alpha: on });
});
it('keeps the other cases when one is saved again, and every other setting', async () => {
seed({ syncCaseState: true, lastUsedCase: 'x', caseSettings: { alpha: on, beta: off } });
await send('PUT', '/api/settings', { caseSettings: { alpha: off } });
expect(readSettings()).toEqual({ syncCaseState: true, lastUsedCase: 'x', caseSettings: { alpha: off, beta: off } });
});
it('refuses a merge that takes the file past the limit and leaves it as it was', async () => {
const full = Object.fromEntries(Array.from({ length: MAX_SHARED_CASES }, (_, i) => [`c${i}`, on]));
seed({ syncCaseState: true, caseSettings: full });
const res = await send('PUT', '/api/settings', { caseSettings: { overflow: on } });
expect(res.status).toBe(400);
expect(Object.keys(readSettings().caseSettings)).toHaveLength(MAX_SHARED_CASES);
expect((await send('PUT', '/api/settings', { caseSettings: { c1: off } })).status).toBe(200);
});
it('moves the options with a renamed case, and drops them when the case is deleted', async () => {
const oldName = caseName();
const newName = caseName();
mkdirSync(join(getCasesDir(), oldName), { recursive: true });
seed({
syncCaseState: true,
caseOrder: [oldName],
lastPickedCase: oldName,
caseSettings: { [oldName]: on, keep: off },
});
const renamed = await send('POST', `/api/cases/${oldName}/rename`, { newName });
expect(renamed.body.success).toBe(true);
expect(readSettings().caseSettings).toEqual({ [newName]: on, keep: off });
expect(readSettings().lastPickedCase).toBe(newName);
const deleted = await send('DELETE', `/api/cases/${newName}`);
expect(deleted.body.success).toBe(true);
expect(readSettings().caseSettings).toEqual({ keep: off });
});
});
describe('what a non-admin is given', () => {
it('has the shared map removed, and keeps the switch the page reads', () => {
const out = settingsForNonAdmin({
syncCaseState: true,
caseOrder: ['a'],
lastUsedCase: 'a',
lastPickedCase: 'a',
caseSettings: { a: on },
agentSkillEnabled: true,
});
expect(out).toEqual({ syncCaseState: true, agentSkillEnabled: true });
});
});
describe('case map helpers', () => {
it('merge, rename and drop leave the input alone and report nothing to do as null', () => {
const map = { a: on, b: off };
expect(mergeCaseSettings(map, { c: on })).toEqual({ a: on, b: off, c: on });
expect(renameCaseSettings(map, 'a', 'z')).toEqual({ b: off, z: on });
expect(renameCaseSettings(map, 'nope', 'z')).toBeNull();
expect(dropCaseSettings(map, 'a')).toEqual({ b: off });
expect(dropCaseSettings(map, 'nope')).toBeNull();
expect(mergeCaseSettings(undefined, { a: on })).toEqual({ a: on });
expect(map).toEqual({ a: on, b: off });
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* @fileoverview Case sync across two devices, end to end: a real server on an ephemeral port (isolated test
* HOME) and two separate Chromium contexts (each with its own localStorage, like two devices). Saves of
* different cases from both contexts both land, the other context takes them on returning to its tab, and
* once the server says sync is off the selected case stops following.
*/
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
import { getCasesDir } from '../src/config/cases-dir.js';
describe('Sync cases across devices in two browsers', () => {
let server: WebServer;
let browser: Browser;
let a: Page;
let b: Page;
const one = `sync-a-${process.pid}`;
const two = `sync-b-${process.pid}`;
const base = () => `http://localhost:${server.boundPort}`;
const put = (body: unknown) =>
fetch(`${base()}/api/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const serverSettings = async () => ((await (await fetch(`${base()}/api/settings`)).json()) as any).data;
const open = async () => {
const page = await (await browser.newContext({ viewport: { width: 1400, height: 900 } })).newPage();
await page.goto(base(), { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
return page;
};
const opts = (page: Page, name: string) => page.evaluate((n) => (window as any).app.getCaseSettings(n), name);
const returnToTab = (page: Page) =>
page.evaluate(async () => {
const app = (window as any).app;
await app._syncCaseStateFromServer();
});
beforeAll(async () => {
for (const name of [one, two]) mkdirSync(join(getCasesDir(), name), { recursive: true });
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
await put({ syncCaseState: true });
a = await open();
b = await open();
for (const page of [a, b]) {
await page.evaluate(async () => {
const app = (window as any).app;
await app.loadAppSettingsFromServer();
});
}
}, 60000);
afterAll(async () => {
await browser?.close();
await server?.stop();
}, 60000);
it("keeps both devices' saves and lets the other device take them", async () => {
await Promise.all([
a.evaluate((n) => (window as any).app.saveCaseSettings(n, { agentTeams: true, opusContext1m: false }), one),
b.evaluate((n) => (window as any).app.saveCaseSettings(n, { agentTeams: false, opusContext1m: false }), two),
]);
await expect
.poll(async () => Object.keys((await serverSettings()).caseSettings ?? {}).sort(), { timeout: 10000 })
.toEqual([one, two].sort());
await returnToTab(b);
await returnToTab(a);
expect(await opts(b, one)).toEqual({ agentTeams: true, opusContext1m: false });
expect(await opts(a, two)).toEqual({ agentTeams: false, opusContext1m: false });
});
it('follows the selected case while sync is on, and stops once the server says it is off', async () => {
await put({ lastPickedCase: two });
await returnToTab(a);
expect(await a.evaluate(() => (document.getElementById('quickStartCase') as HTMLSelectElement).value)).toBe(two);
await put({ syncCaseState: false, lastPickedCase: one });
await returnToTab(a);
expect(await a.evaluate(() => (document.getElementById('quickStartCase') as HTMLSelectElement).value)).toBe(two);
expect(await a.evaluate(() => (window as any).app.loadAppSettingsFromStorage().syncCaseState)).toBe(false);
});
});
+331
View File
@@ -0,0 +1,331 @@
/**
* @fileoverview Opt-in case sync, client side: the schema accepts the setting and a per-case map, and the
* session-ui helpers only touch the server when the setting is on, send one case at a time, report a failed
* save, and obey the server's answer about whether sync is on. Evaluates the real session-ui.js.
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
const SRC = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
// The real write-result handling the case sync reuses (a toast for a failed save, read-only after a 403).
const SETTINGS_SRC = readFileSync(new URL('../src/web/public/settings-ui.js', import.meta.url), 'utf-8');
type Reply = { status?: number; body?: unknown } | 'throw';
type Handler = (url: string, init?: { method?: string; body?: string }) => Reply | Promise<Reply>;
function makeApp(settings: Record<string, unknown>, onFetch: Handler = () => ({ body: { success: true, data: {} } })) {
const store = new Map<string, string>();
const calls: { method: string; body: unknown }[] = [];
const toasts: string[] = [];
const select = { value: 'alpha' };
const hidden = new Set<string>();
const popover = (id: string) => ({ classList: { add: (c: string) => c === 'hidden' && void hidden.add(id) } });
function CodemanApp(this: unknown) {}
const ctx = vm.createContext({
CodemanApp,
window: {},
document: {
getElementById: (id: string) => (id.startsWith('caseSettingsPopover') ? popover(id) : select),
addEventListener: () => {},
},
localStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
},
fetch: async (url: string, init?: { method?: string; body?: string }) => {
calls.push({ method: init?.method ?? 'GET', body: init?.body ? JSON.parse(init.body) : undefined });
const r = await onFetch(url, init);
if (r === 'throw') throw new Error('offline');
const status = r.status ?? 200;
const res = { ok: status < 400, status, json: async () => r.body ?? {}, clone: () => res };
return res;
},
console,
});
vm.runInContext(SRC, ctx);
vm.runInContext(SETTINGS_SRC, ctx);
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, any>;
app.loadAppSettingsFromStorage = () => settings;
app.saveAppSettingsToStorage = (s: Record<string, unknown>) => Object.assign(settings, s);
app.showToast = (m: string) => void toasts.push(m);
app.cases = [{ name: 'alpha' }, { name: 'beta' }];
app.updateDirDisplayForCase = () => {};
app.updateMobileCaseLabel = () => {};
app.updateCasePickerInput = () => {};
app.closeCasePicker = () => {};
return { app, store, calls, toasts, select, settings, hidden };
}
const settle = () => new Promise((r) => setTimeout(r, 0));
const entry = { agentTeams: true, opusContext1m: false };
describe('syncCaseState schema', () => {
it('accepts the setting with a per-case map, and rejects junk', () => {
expect(SettingsUpdateSchema.safeParse({ syncCaseState: true, caseSettings: { demo: entry } }).success).toBe(true);
expect(SettingsUpdateSchema.safeParse({ caseSettings: { demo: { agentTeams: 'yes' } } }).success).toBe(false);
expect(
SettingsUpdateSchema.safeParse({ caseSettings: { demo: { agentTeams: true, opusContext1m: true, x: 1 } } })
.success
).toBe(false);
});
});
describe('saving a case', () => {
it('stays on this browser while the setting is off', async () => {
const { app, store, calls } = makeApp({});
app.saveCaseSettings('demo', entry);
await settle();
expect(store.get('caseSettings_demo')).toContain('agentTeams');
expect(calls).toEqual([]);
});
it('sends only that case (no read of the server map first), and nothing when read-only', async () => {
const on = makeApp({ syncCaseState: true });
on.app.saveCaseSettings('demo', entry);
await settle();
expect(on.calls).toEqual([{ method: 'PUT', body: { caseSettings: { demo: entry } } }]);
const ro = makeApp({ syncCaseState: true });
ro.app._settingsReadOnly = () => true;
ro.app.saveCaseSettings('demo', entry);
await settle();
expect(ro.calls).toEqual([]);
});
it('sends rapid saves one after another, in the order they were made', async () => {
let release!: () => void;
const first = new Promise<void>((r) => (release = r));
let n = 0;
const { app, calls } = makeApp({ syncCaseState: true }, async () => {
if (++n === 1) await first;
return { body: { success: true, data: {} } };
});
app.saveCaseSettings('demo', { agentTeams: true, opusContext1m: true });
app.saveCaseSettings('demo', { agentTeams: false, opusContext1m: false });
await settle();
expect(calls).toHaveLength(1); // the second waits for the first
release();
await app._caseSyncChain;
expect(calls.map((c) => (c.body as any).caseSettings.demo.agentTeams)).toEqual([true, false]);
});
it.each([
['an HTTP 500', { status: 500, body: { success: false, error: 'boom' } }],
['a dropped connection', 'throw' as const],
])('keeps the local choice and says it was not synced after %s', async (_label, reply) => {
const { app, store, toasts } = makeApp({ syncCaseState: true }, () => reply as Reply);
app.saveCaseSettings('demo', entry);
await app._caseSyncChain;
expect(JSON.parse(store.get('caseSettings_demo')!)).toEqual(entry);
expect(toasts).toHaveLength(1);
expect(toasts[0]).toContain('not on the server');
expect(toasts[0]).toContain('until the next sync');
});
it('hands an admin-only refusal to the read-only handling, without a toast', async () => {
const forbidden = { status: 403, body: { success: false, errorCode: 'FORBIDDEN' } };
const { app, toasts } = makeApp({ syncCaseState: true }, () => forbidden);
app._noteSettingsWriteRefusal = async (res: { status: number }) => {
if (res.status !== 403) return false;
app._settingsWriteForbidden = true;
return true;
};
app._settingsReadOnly = () => app._settingsWriteForbidden === true;
app.saveCaseSettings('demo', entry);
await app._caseSyncChain;
expect(toasts).toEqual([]);
expect(app._caseStateSyncOn()).toBe(false); // nothing more is sent from this page
});
});
describe('recording the picked case', () => {
it('writes lastPickedCase with lastUsedCase only while sync is on', async () => {
const on = makeApp({ syncCaseState: true });
await on.app.saveLastUsedCase('beta');
expect(on.calls[0].body).toEqual({ lastUsedCase: 'beta', lastPickedCase: 'beta' });
const off = makeApp({});
await off.app.saveLastUsedCase('beta');
expect(off.calls[0].body).toEqual({ lastUsedCase: 'beta' });
});
});
describe('a pick that did not reach the server', () => {
it('is not undone by the next tab return, and following resumes once a pick is stored', async () => {
let putOk = false;
const { app, select } = makeApp({ syncCaseState: true }, (_url, init) =>
init?.method === 'PUT'
? putOk
? { body: { success: true } }
: { status: 500, body: { error: 'x' } }
: { body: { success: true, data: { syncCaseState: true, lastPickedCase: 'alpha' } } }
);
select.value = 'beta';
await app.saveLastUsedCase('beta'); // the pick fails
await app._syncCaseStateFromServer(); // the server still says alpha
expect(select.value).toBe('beta');
putOk = true;
await app.saveLastUsedCase('beta');
await app._syncCaseStateFromServer();
expect(select.value).toBe('alpha');
});
it('lands before the copy a tab return reads', async () => {
const order: string[] = [];
const { app } = makeApp({ syncCaseState: true }, async (_url, init) => {
order.push(init?.method ?? 'GET');
return { body: { success: true, data: { syncCaseState: true } } };
});
void app.saveLastUsedCase('beta');
await app._syncCaseStateFromServer();
expect(order).toEqual(['PUT', 'GET']);
});
});
describe('taking the server copy', () => {
const server = { syncCaseState: true, caseSettings: { demo: entry } };
it('writes the options into the local keys only while the server says sync is on', () => {
const a = makeApp({});
a.app._applyServerCaseSettings(server);
expect(JSON.parse(a.store.get('caseSettings_demo')!)).toEqual(entry);
const b = makeApp({});
b.app._applyServerCaseSettings({ ...server, syncCaseState: false });
expect(b.store.size).toBe(0);
});
it('never applies a map to a read-only identity, even one the server handed over', () => {
const { app, store } = makeApp({ syncCaseState: true });
app._settingsReadOnly = () => true;
app._applyServerCaseSettings(server);
expect(store.size).toBe(0);
});
it('leaves a case alone while this device is still saving it', async () => {
let release!: () => void;
const hold = new Promise<void>((r) => (release = r));
const { app, store } = makeApp({ syncCaseState: true }, async () => {
await hold;
return { body: { success: true, data: {} } };
});
app.saveCaseSettings('demo', { agentTeams: false, opusContext1m: false });
app._applyServerCaseSettings({ syncCaseState: true, caseSettings: { demo: entry, other: entry } });
expect(JSON.parse(store.get('caseSettings_demo')!)).toEqual({ agentTeams: false, opusContext1m: false });
expect(JSON.parse(store.get('caseSettings_other')!)).toEqual(entry);
release();
await app._caseSyncChain;
});
});
describe('returning to the tab', () => {
const env = (data: Record<string, unknown>) => ({ body: { success: true, data } });
it('follows the selected case and the options while sync is on', async () => {
const { app, select, store } = makeApp({ syncCaseState: true }, () =>
env({ syncCaseState: true, lastPickedCase: 'beta', caseSettings: { beta: entry } })
);
await app._syncCaseStateFromServer();
expect(select.value).toBe('beta');
expect(JSON.parse(store.get('caseSettings_beta')!)).toEqual(entry);
});
it('stops following when another device turned sync off, and the cached switch follows the server', async () => {
const { app, select, store, settings } = makeApp({ syncCaseState: true }, () =>
env({ syncCaseState: false, lastPickedCase: 'beta', caseSettings: { beta: entry } })
);
await app._syncCaseStateFromServer();
expect(select.value).toBe('alpha');
expect(store.size).toBe(0);
expect(settings.syncCaseState).toBe(false);
expect(app._caseStateSyncOn()).toBe(false);
});
it('does not follow a case a quick-start or an agent started, only one picked in a picker', async () => {
const { app, select } = makeApp({ syncCaseState: true }, () =>
env({ syncCaseState: true, lastUsedCase: 'beta', lastPickedCase: 'alpha' })
);
await app._syncCaseStateFromServer();
expect(select.value).toBe('alpha');
});
it('closes an open case-options popover when the follow or an import changes what it shows', async () => {
const followed = makeApp({ syncCaseState: true }, () => env({ syncCaseState: true, lastPickedCase: 'beta' }));
await followed.app._syncCaseStateFromServer();
expect([...followed.hidden].sort()).toEqual(['caseSettingsPopover', 'caseSettingsPopoverMobile']);
const imported = makeApp({ syncCaseState: true }, () =>
env({ syncCaseState: true, caseSettings: { alpha: entry } })
);
await imported.app._syncCaseStateFromServer();
expect(imported.hidden.size).toBe(2);
const unchanged = makeApp({ syncCaseState: true }, () => env({ syncCaseState: true, lastPickedCase: 'alpha' }));
await unchanged.app._syncCaseStateFromServer();
expect(unchanged.hidden.size).toBe(0);
});
it('does not undo a pick or a case save made while its read was out', async () => {
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const { app, select, store } = makeApp({ syncCaseState: true }, async (_url, init) => {
if (init?.method === 'PUT') return { body: { success: true, data: {} } };
await gate; // the server read this copy before the pick and the save below reached it
return env({
syncCaseState: true,
lastPickedCase: 'alpha',
caseSettings: { beta: { agentTeams: false, opusContext1m: true } },
});
});
const refresh = app._syncCaseStateFromServer();
await settle();
select.value = 'beta';
await app.saveLastUsedCase('beta');
app.saveCaseSettings('beta', entry);
await app._caseSyncChain;
release();
await refresh;
expect(select.value).toBe('beta');
expect(JSON.parse(store.get('caseSettings_beta')!)).toEqual(entry);
});
it('asks for nothing on a tab return while this device has sync off', async () => {
const { app, select, calls } = makeApp({}, () => env({ syncCaseState: true, lastUsedCase: 'beta' }));
await app._syncCaseStateFromServer();
expect(calls).toEqual([]);
expect(select.value).toBe('alpha');
});
it('does nothing for a read-only identity', async () => {
const { app, calls } = makeApp({ syncCaseState: true });
app._settingsReadOnly = () => true;
await app._syncCaseStateFromServer();
expect(calls).toEqual([]);
});
it('keeps what it has when the request fails', async () => {
const { app, select } = makeApp({ syncCaseState: true }, () => ({ status: 500, body: { error: 'x' } }));
await app._syncCaseStateFromServer();
expect(select.value).toBe('alpha');
});
it('discards an older refresh that answers after a newer one', async () => {
const releases: (() => void)[] = [];
const answers = [
env({ syncCaseState: true, lastPickedCase: 'beta' }), // the older request
env({ syncCaseState: true, lastPickedCase: 'alpha' }), // the newer request
];
let i = 0;
const { app, select } = makeApp({ syncCaseState: true }, async () => {
const mine = answers[i++];
await new Promise<void>((r) => releases.push(r));
return mine;
});
const older = app._syncCaseStateFromServer();
await settle();
const newer = app._syncCaseStateFromServer();
await settle();
releases[1](); // the newer answers first
await newer;
releases[0](); // then the older one arrives late
await older;
expect(select.value).toBe('alpha');
});
});
+1 -1
View File
@@ -125,7 +125,7 @@ async function boot(device: Device = 'desktop', stored?: Settings) {
};
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
var KeyboardAccessoryBar = { setMode() {} };
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var VoiceInput = { SILENCE_TIMEOUT_SECONDS: [0, 3, 6, 15, 30], _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
var DEFAULT_VOICE_KEYTERMS = '';`,
...SCRIPTS,
+439
View File
@@ -0,0 +1,439 @@
import fs from 'node:fs';
import vm from 'node:vm';
import { expect, test } from 'vitest';
const source = fs.readFileSync(new URL('../src/web/public/voice-input.js', import.meta.url), 'utf8');
function result(text, final = true) {
return Object.assign([{ transcript: text }], { isFinal: final });
}
function harness({
platform = 'Android',
ua = 'Mozilla/5.0 (Linux; Android 15) Chrome/141.0',
touch = 5,
delay = 0,
} = {}) {
let now = 1000,
next = 0,
starts = 0,
mic = 0;
const timers = new Map(),
frames = new Map(),
inserted = [],
previews = [],
attrs = {};
const bars = Array.from({ length: 5 }, () => ({
active: false,
classList: {
toggle(_name, on) {
this.owner.active = on;
},
},
}));
for (const bar of bars) bar.classList.owner = bar;
const meter = {
children: bars,
setAttribute(k, v) {
attrs[k] = v;
},
};
class Recognition {
start() {
starts++;
this.results = [];
}
stop() {}
abort() {}
// Chromium Android promotes every native partial into a new final when continuous=true.
partial(text) {
if (this.continuous) this.results.push(result(text));
else this.results = [result(text, false)];
this.onresult({ resultIndex: this.continuous ? this.results.length - 1 : 0, results: this.results });
}
final(text) {
if (this.continuous) this.results.push(result(text));
else this.results = [result(text)];
this.onresult({ resultIndex: this.continuous ? this.results.length - 1 : 0, results: this.results });
}
}
const context = vm.createContext({
console,
window: { SpeechRecognition: Recognition },
navigator: {
userAgent: ua,
platform: platform === 'Windows' ? 'Win32' : 'Linux aarch64',
userAgentData: { platform },
maxTouchPoints: touch,
mediaDevices: {
getUserMedia() {
mic++;
throw Error('second microphone capture');
},
},
},
localStorage: { getItem: () => JSON.stringify({ silenceTimeout: delay }) },
app: { showToast() {} },
WebSocket: { OPEN: 1 },
Date: class extends Date {
static now() {
return now;
}
},
setTimeout(fn, ms) {
timers.set(++next, { fn, ms });
return next;
},
clearTimeout: (id) => timers.delete(id),
clearInterval() {},
requestAnimationFrame(fn) {
frames.set(++next, fn);
return next;
},
cancelAnimationFrame: (id) => frames.delete(id),
});
const voice = vm.runInContext(source + '\nVoiceInput', context);
for (const name of [
'_updateButtons',
'_startDurationTimer',
'_stopDurationTimer',
'_hidePreview',
'_hideVoiceSendBtn',
])
voice[name] = () => {};
voice.previewEl = { querySelector: () => meter, remove() {} };
voice._showPreview = (text) => previews.push(text);
voice._insertText = (text) => inserted.push(text);
voice._initRecognition();
return {
voice,
inserted,
previews,
bars,
attrs,
timers,
frames,
get starts() {
return starts;
},
get mic() {
return mic;
},
frame(delta = 120) {
now += delta;
const jobs = [...frames.values()];
frames.clear();
for (const fn of jobs) fn(now);
},
event(results, index = 0) {
voice._onWebSpeechResult({ results, resultIndex: index });
},
finish() {
voice.stop();
voice._onWebSpeechEnd();
},
};
}
test('Android growing partial phrases and final insert exactly five words', () => {
const h = harness();
h.voice._startWebSpeech();
for (const phrase of ['one', 'one two', 'one two three', 'one two three four', 'one two three four five'])
h.voice.recognition.partial(phrase);
h.voice.recognition.final('one two three four five');
h.finish();
expect(h.inserted).toEqual(['one two three four five']);
expect(h.mic).toBe(0);
});
test('Android desktop-site mode also avoids continuous partial-to-final promotion', () => {
const h = harness({ platform: 'Linux', ua: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/141.0' });
expect(h.voice.recognition.continuous).toBe(false);
});
test('Windows keeps continuous recognition and repeated snapshots do not append twice', () => {
const h = harness({ platform: 'Windows', ua: 'Mozilla/5.0 (Windows NT 10.0) Chrome/141.0' });
h.voice._startWebSpeech();
expect(h.voice.recognition.continuous).toBe(true);
h.event([result('yes yes'), result('do it', false)], 0);
h.event([result('yes yes'), result('do it', false)], 0);
h.event([result('yes yes'), result('do it')], 1);
h.event([result('yes yes'), result('do it')], 0);
h.finish();
expect(h.inserted).toEqual(['yes yes do it']);
});
test('an interim correction replaces the previous text, including removed interim results', () => {
const h = harness();
h.voice._startWebSpeech();
h.event([result('keep this'), result('wrong', false)]);
h.event([result('keep this'), result('correct', false)], 1);
expect(h.previews.at(-1)).toBe('keep this correct');
h.event([result('keep this')], 1);
h.finish();
expect(h.inserted).toEqual(['keep this']);
});
for (const delay of [0, 3, 6, 15, 30])
test(`Android preserves intentional repeated phrases across native ends with ${delay}s silence setting`, () => {
const h = harness({ delay });
h.voice._startWebSpeech();
h.voice.recognition.final('go go');
h.voice._onWebSpeechEnd();
expect(h.starts).toBe(2);
h.voice.recognition.partial('go go');
h.voice.recognition.final('go go');
h.finish();
expect(h.inserted).toEqual(['go go go go']);
});
test('speech events animate bars, silence clears them, and Stop cancels animation', () => {
const h = harness();
h.voice._startWebSpeech();
expect(h.bars.filter((b) => b.active).length).toBe(0);
expect(typeof h.voice.recognition.onsoundstart).toBe('function');
h.voice.recognition.onsoundstart();
h.frame();
const count = h.bars.filter((b) => b.active).length;
expect(count).toBeGreaterThan(0);
h.frame(300);
expect(h.bars.filter((b) => b.active).length).not.toBe(count);
h.voice.recognition.onsoundend();
h.frame();
expect(h.bars.filter((b) => b.active).length).toBe(0);
h.voice.recognition.onspeechstart();
h.frame();
expect(h.bars.some((b) => b.active)).toBe(true);
h.finish();
expect(h.frames.size).toBe(0);
expect(h.mic).toBe(0);
});
test('engines without sound events briefly animate on transcript updates', () => {
const h = harness();
h.voice._startWebSpeech();
h.voice.recognition.partial('hello');
h.frame();
expect(h.bars.some((b) => b.active)).toBe(true);
h.frame(1000);
expect(h.bars.filter((b) => b.active).length).toBe(0);
h.finish();
});
test('a new dictation starts with no words from the previous dictation', () => {
const h = harness();
h.voice._startWebSpeech();
h.voice.recognition.final('first');
h.finish();
h.voice._startWebSpeech();
h.voice.recognition.final('second');
h.finish();
expect(h.inserted).toEqual(['first', 'second']);
});
test('default 3-second silence deadline does not stop Android after a 750ms stable interim', () => {
const h = harness({ delay: 3 });
h.voice._startWebSpeech();
h.voice.recognition.partial('five words spoken very clearly');
expect([...h.timers.values()].map((t) => t.ms)).toEqual([3000]);
});
test('interim-only native end cannot be reinserted by an old stability callback', () => {
const h = harness({ delay: 3 });
h.voice._startWebSpeech();
h.voice.recognition.partial('five words spoken very clearly');
h.voice._onWebSpeechEnd();
expect(h.starts).toBe(2);
// Execute the callbacks that were queued before the native end, in deadline order.
const pending = [...h.timers.entries()].sort((a, b) => a[1].ms - b[1].ms);
for (const [id, timer] of pending) {
if (h.timers.delete(id)) timer.fn();
}
h.voice._onWebSpeechEnd();
expect(h.inserted).toEqual(['five words spoken very clearly']);
});
function stopHarness() {
const timers = new Map(),
inserted = [],
previews = [],
toasts = [];
let next = 0,
micRequests = 0,
stops = 0,
starts = 0;
const context = vm.createContext({
console,
app: { showToast: (text) => toasts.push(text) },
navigator: {
userAgent: 'Mozilla/5.0 (Linux; Android 15) Chrome/141.0',
mediaDevices: {
getUserMedia: async () => {
micRequests++;
return { getTracks: () => [] };
},
},
},
localStorage: { getItem: () => JSON.stringify({ silenceTimeout: 0 }) },
WebSocket: { OPEN: 1 },
setTimeout: (fn, ms) => {
timers.set(++next, { fn, ms });
return next;
},
clearTimeout: (id) => timers.delete(id),
clearInterval() {},
requestAnimationFrame() {
return 1;
},
cancelAnimationFrame() {},
});
const [voice, claude] = vm.runInContext(source + '\n[VoiceInput,ClaudeVoiceProvider]', context);
voice.recognition = {
start() {
starts++;
},
stop() {
stops++;
},
abort() {},
};
voice.supported = true;
for (const name of [
'_updateButtons',
'_startDurationTimer',
'_stopDurationTimer',
'_startLevelMeter',
'_stopLevelMeter',
'_hidePreview',
'_hideVoiceSendBtn',
])
voice[name] = () => {};
voice._showPreview = (text) => previews.push(text);
voice._insertText = (text) => inserted.push(text);
return {
voice,
claude,
timers,
inserted,
previews,
toasts,
get micRequests() {
return micRequests;
},
get stops() {
return stops;
},
get starts() {
return starts;
},
};
}
function stopResult(voice, text, final = true) {
voice._onWebSpeechResult({ resultIndex: 0, results: [Object.assign([{ transcript: text }], { isFinal: final })] });
}
test('browser recognition owns the microphone without a competing cosmetic recorder', async () => {
const h = stopHarness();
h.voice._startWebSpeech();
await Promise.resolve();
expect(h.starts).toBe(1);
expect(h.micRequests).toBe(0);
stopResult(h.voice, 'live Android words', false);
expect(h.previews.at(-1)).toBe('live Android words');
});
test('tap Stop accepts a delayed final transcript and inserts once on end', () => {
const h = stopHarness();
h.voice._startWebSpeech();
h.voice.stop();
expect(h.stops).toBe(1);
expect(h.inserted).toEqual([]);
stopResult(h.voice, 'words arriving after stop');
h.voice._onWebSpeechEnd();
h.voice._onWebSpeechEnd();
expect(h.inserted).toEqual(['words arriving after stop']);
expect(h.voice.isRecording).toBe(false);
expect(h.starts).toBe(1);
});
test('repeated Stop does not finalize early or restart recognition', () => {
const h = stopHarness();
h.voice._startWebSpeech();
h.voice.stop();
h.voice.stop();
stopResult(h.voice, 'last phrase');
h.voice._onWebSpeechEnd();
expect(h.inserted).toEqual(['last phrase']);
expect(h.stops).toBe(1);
expect(h.starts).toBe(1);
});
test('stop timeout preserves interim words if the browser never ends', () => {
const h = stopHarness();
h.voice._startWebSpeech();
stopResult(h.voice, 'interim words', false);
h.voice.stop();
const timeout = [...h.timers.values()].find((t) => t.ms === 3000);
expect(timeout, 'bounded wait for the browser final result').toBeTruthy();
timeout.fn();
expect(h.inserted).toEqual(['interim words']);
expect(h.voice.isRecording).toBe(false);
stopResult(h.voice, 'too late');
expect(h.inserted).toEqual(['interim words']);
});
test('manual thinking pauses still restart natural recognition ends and retain words', () => {
const h = stopHarness();
h.voice._startWebSpeech();
stopResult(h.voice, 'first phrase', false);
h.voice._onWebSpeechEnd();
stopResult(h.voice, 'second phrase');
h.voice.stop();
h.voice._onWebSpeechEnd();
expect(h.inserted).toEqual(['first phrase second phrase']);
expect(h.starts).toBe(2);
});
test('Claude still accepts its final transcript after finalize', () => {
const h = stopHarness();
h.voice.isRecording = true;
h.voice._activeProvider = 'claude';
h.claude._ws = { readyState: 1 };
let finalized = 0;
h.claude.stop = () => finalized++;
h.voice.stop();
expect(h.voice.isRecording).toBe(true);
h.voice._accumulatedFinal = 'Claude final';
h.voice.stop(true);
expect(h.inserted).toEqual(['Claude final']);
expect(finalized).toBeGreaterThanOrEqual(1);
});
test('failed start ends immediately with an error instead of waiting for a final result', () => {
const h = stopHarness();
h.voice.recognition.start = () => {
throw new Error('fixture failure');
};
h.voice._startWebSpeech();
expect(h.voice.isRecording).toBe(false);
expect(h.toasts[0]).toMatch(/fixture failure/);
expect(h.timers.size).toBe(0);
});
test('failed stop still inserts available words and clears the finishing timer', () => {
const h = stopHarness();
h.voice._startWebSpeech();
stopResult(h.voice, 'existing words', false);
h.voice.recognition.stop = () => {
throw new Error('already stopped');
};
h.voice.stop();
expect(h.inserted).toEqual(['existing words']);
expect(h.voice.isRecording).toBe(false);
expect(h.timers.size).toBe(0);
});
test('cleanup during finishing releases recognition and cannot insert a second result', () => {
const h = stopHarness();
h.voice._startWebSpeech();
stopResult(h.voice, 'keep these words', false);
h.voice.stop();
h.voice.cleanup();
expect(h.voice.isRecording).toBe(false);
expect(h.timers.size).toBe(0);
stopResult(h.voice, 'stale result');
h.voice._onWebSpeechEnd();
expect(h.inserted).toEqual(['keep these words']);
expect(h.voice.recognition).toBe(null);
});
test('fatal browser error ends immediately without retrying or dropping existing words', () => {
const h = stopHarness();
h.voice._startWebSpeech();
stopResult(h.voice, 'existing words', false);
h.voice._onWebSpeechError({ error: 'network' });
h.voice._onWebSpeechEnd();
expect(h.voice.isRecording).toBe(false);
expect(h.inserted).toEqual(['existing words']);
expect(h.starts).toBe(1);
expect(h.timers.size).toBe(0);
expect(h.toasts[0]).toMatch(/internet/);
});
+81
View File
@@ -0,0 +1,81 @@
// Real Chromium rendering/input; native Android/Windows recognition is simulated.
import { readFileSync } from 'node:fs';
import { chromium, type Browser } from 'playwright';
import { afterAll, beforeAll, expect, it } from 'vitest';
const source = readFileSync(new URL('../src/web/public/voice-input.js', import.meta.url), 'utf8');
const styles = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf8');
let browser: Browser;
beforeAll(async () => {
browser = await chromium.launch();
});
afterAll(async () => {
await browser?.close();
});
it.each([
[390, 'Android', 'Mozilla/5.0 (Linux; Android 15) Chrome/141.0'],
[820, 'Linux', 'Mozilla/5.0 (X11; Linux x86_64) Chrome/141.0'],
[1292, 'Windows', 'Mozilla/5.0 (Windows NT 10.0) Chrome/141.0'],
] as const)('dictation at %ipx (%s)', async (width, platform, ua) => {
const page = await browser.newPage({ viewport: { width, height: 900 }, hasTouch: true, userAgent: ua });
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.setContent(
'<button id="mic" style="position:fixed;top:20px;left:20px">Microphone</button><div class="voice-compose-overlay"><textarea class="paste-textarea" aria-label="Transcript"></textarea></div>'
);
await page.addStyleTag({ content: styles });
await page.addScriptTag({
content: `const platform = ${JSON.stringify(platform)};
Object.defineProperty(navigator,'userAgentData',{value:{platform},configurable:true});
Object.defineProperty(navigator,'platform',{value:platform==='Windows'?'Win32':'Linux x86_64',configurable:true});
window.micRequests=0;
Object.defineProperty(navigator,'mediaDevices',{value:{getUserMedia(){window.micRequests++;return Promise.reject(Error('Unexpected second microphone'));}},configurable:true});
window.SpeechRecognition=class{
start(){this.results=[];}
partial(text){this.text=text;const r=Object.assign([{transcript:text}],{isFinal:platform!=='Windows'&&this.continuous});if(r.isFinal)this.results.push(r);else this.results=[r];this.onresult({resultIndex:r.isFinal?this.results.length-1:0,results:this.results});}
final(text){this.text=text;const r=Object.assign([{transcript:text}],{isFinal:true});if(platform!=='Windows'&&this.continuous)this.results.push(r);else this.results=[r];this.onresult({resultIndex:0,results:this.results});}
stop(){setTimeout(()=>{this.final(this.text);this.onend();},50);}
abort(){}
};
window.app={activeSessionId:'fixture',showToast(){}};
`,
});
await page.addScriptTag({
content:
source +
`\nwindow.voice=VoiceInput;VoiceInput._getDeepgramConfig=()=>({provider:'webspeech',insertMode:'compose',silenceTimeout:0});document.getElementById('mic').onclick=()=>VoiceInput.toggle();`,
});
await page.click('#mic');
expect(await page.evaluate(() => (window as any).voice.recognition.continuous)).toBe(platform === 'Windows');
await page.evaluate(() => (window as any).voice.recognition.onsoundstart());
await page.waitForFunction(() => document.querySelectorAll('.voice-level-bar.active').length > 0);
const counts = new Set();
for (let i = 0; i < 8; i++) {
await page.waitForTimeout(90);
counts.add(await page.locator('.voice-level-bar.active').count());
}
expect(counts.size, 'speech bars must animate').toBeGreaterThan(1);
await page.evaluate(() => (window as any).voice.recognition.onsoundend());
await page.waitForFunction(() => document.querySelectorAll('.voice-level-bar.active').length === 0);
await page.evaluate(() => {
const voice = (window as any).voice;
for (const text of ['one', 'one two', 'one two three', 'one two three four', 'one two three four five'])
voice.recognition.partial(text);
voice.recognition.final('one two three four five');
});
expect(await page.evaluate(() => (window as any).voice._previewTextNode.textContent)).toBe('one two three four five');
await page.click('#mic');
await page.waitForFunction(() => document.querySelector('textarea')?.value === 'one two three four five');
expect(await page.evaluate(() => (window as any).voice.isRecording)).toBe(false);
await page.click('#mic');
await page.evaluate(() => {
const voice = (window as any).voice;
voice.recognition.final('go go');
voice.recognition.onend();
voice.recognition.partial('go go');
});
await page.click('#mic');
await page.waitForFunction(() => document.querySelector('textarea')?.value === 'go go go go');
expect(await page.evaluate(() => (window as any).micRequests)).toBe(0);
expect(errors).toEqual([]);
await page.close();
});
+394
View File
@@ -0,0 +1,394 @@
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
afterEach(() => vi.useRealTimers());
const source = readFileSync(new URL('../src/web/public/voice-input.js', import.meta.url), 'utf8');
function load(silenceTimeout?: unknown, overrides: Record<string, unknown> = {}) {
vi.useRealTimers();
vi.useFakeTimers();
const context = vm.createContext({
// The context's own Date ignores fake timers; run lengths must follow them.
Date,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
requestAnimationFrame: () => 1,
cancelAnimationFrame: () => {},
app: { showToast: vi.fn() },
console,
navigator: {},
WebSocket: { OPEN: 1 },
localStorage: { getItem: () => JSON.stringify({ silenceTimeout }) },
...overrides,
});
const providers = vm.runInContext(`${source}\n[DeepgramProvider, ClaudeVoiceProvider, VoiceInput]`, context);
return providers as Array<Record<string, any>>;
}
describe('voice silence timeout', () => {
for (const [index, name, legacy] of [
[0, 'Deepgram', 3000],
[1, 'Claude', 6000],
[2, 'browser', 3000],
] as const) {
it(`${name} keeps its default and accepts longer thinking pauses`, () => {
for (const [setting, delay] of [
[undefined, legacy],
[15, 15000],
[30, 30000],
] as const) {
const provider = load(setting)[index];
provider.isRecording = true;
provider.stop = vi.fn();
provider._resetSilenceTimeout();
vi.advanceTimersByTime(delay - 1);
expect(provider.stop).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(provider.stop).toHaveBeenCalledOnce();
}
});
it(`${name} resets its deadline and cancels it in manual mode`, () => {
const providers = load(15);
const provider = providers[index];
provider.isRecording = true;
provider.stop = vi.fn();
provider._resetSilenceTimeout();
vi.advanceTimersByTime(10000);
provider._resetSilenceTimeout();
vi.advanceTimersByTime(10000);
expect(provider.stop).not.toHaveBeenCalled();
providers[2]._getDeepgramConfig = () => ({ silenceTimeout: 0 });
provider._resetSilenceTimeout();
vi.advanceTimersByTime(120000);
expect(provider.stop).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});
}
it.each([null, -1, '15', 1.5, 999])('ignores invalid local values: %s', (setting) => {
const voice = load(setting)[2];
expect(voice.getSilenceTimeoutMs(6000)).toBe(6000);
});
it('does not stop interim dictation at the former 750ms stability deadline', () => {
for (const setting of [0, 3, 15]) {
const voice = load(setting)[2];
voice.isRecording = true;
voice._showPreview = vi.fn();
voice.stop = vi.fn();
voice._onWebSpeechResult({ results: [[{ transcript: 'thinking' }]] });
vi.advanceTimersByTime(750);
expect(voice.stop).not.toHaveBeenCalled();
}
});
it('restarts browser recognition without extending the silence deadline', () => {
const voice = load(15)[2];
voice.isRecording = true;
voice._recordingStartedAt = Date.now() - 1000;
voice.recognition = { start: vi.fn() };
voice.stop = vi.fn();
voice._hasReceivedResult = true;
voice._resetSilenceTimeout();
vi.advanceTimersByTime(10000);
voice._onWebSpeechEnd();
expect(voice.recognition.start).toHaveBeenCalledOnce();
vi.advanceTimersByTime(5000);
expect(voice.stop).toHaveBeenCalledOnce();
});
it('keeps final browser phrases and Safari interim text until an explicit stop', () => {
const voice = load(0)[2];
voice.isRecording = true;
voice._activeProvider = 'webspeech';
for (const method of ['_showPreview', '_stopDurationTimer', '_stopLevelMeter', '_updateButtons', '_hidePreview'])
voice[method] = vi.fn();
voice._insertText = vi.fn();
voice.recognition = { stop: vi.fn() };
const result = [{ transcript: 'first phrase ' }];
Object.assign(result, { isFinal: true });
voice._onWebSpeechResult({ resultIndex: 0, results: [result] });
expect(voice.isRecording).toBe(true);
expect(voice._insertText).not.toHaveBeenCalled();
const interim = [{ transcript: 'thinking aloud' }];
voice._onWebSpeechResult({ resultIndex: 1, results: [result, interim] });
voice.stop();
voice.stop();
expect(voice._insertText).not.toHaveBeenCalled();
voice._onWebSpeechEnd();
expect(voice._insertText).toHaveBeenCalledExactlyOnceWith('first phrase thinking aloud');
});
it('preserves mixed final/interim results across browser recognition restarts', () => {
const voice = load(0)[2];
voice.isRecording = true;
voice._recordingStartedAt = Date.now() - 1000;
voice._showPreview = vi.fn();
voice.recognition = { start: vi.fn() };
const final = Object.assign([{ transcript: 'first ' }], { isFinal: true });
const interim = [{ transcript: 'second' }];
voice._onWebSpeechResult({ resultIndex: 0, results: [final, interim] });
expect(voice._accumulatedFinal + voice._pendingInterim).toBe('first second');
voice._onWebSpeechEnd();
voice._onWebSpeechResult({ resultIndex: 0, results: [[{ transcript: 'third' }]] });
expect(voice._accumulatedFinal + voice._pendingInterim).toBe('first second third');
});
it('ignores repeated stop requests while waiting for Claude final text', () => {
const [_, provider, voice] = load(0);
voice.isRecording = true;
voice._activeProvider = 'claude';
provider._ws = { readyState: 1 };
provider.stop = vi.fn(() => {
provider._finalized = true;
});
voice._insertText = vi.fn();
voice.stop();
voice.stop();
expect(voice._insertText).not.toHaveBeenCalled();
expect(voice.isRecording).toBe(true);
});
it('keeps a corrected final through the configured silence deadline', () => {
const voice = load(3)[2];
voice.isRecording = true;
voice._showPreview = vi.fn();
voice.stop = vi.fn();
voice._onWebSpeechResult({ resultIndex: 0, results: [[{ transcript: 'hello' }]] });
const final = Object.assign([{ transcript: 'hello' }], { isFinal: true });
voice._onWebSpeechResult({ resultIndex: 0, results: [final] });
vi.advanceTimersByTime(750);
expect(voice.stop).not.toHaveBeenCalled();
expect(voice._accumulatedFinal + voice._pendingInterim).toBe('hello');
});
it('round-trips the setting through server validation', () => {
for (const silenceTimeout of [0, 3, 6, 15, 30]) {
expect(SettingsUpdateSchema.parse({ voiceSettings: { silenceTimeout } }).voiceSettings).toEqual({
silenceTimeout,
});
}
expect(SettingsUpdateSchema.safeParse({ voiceSettings: { silenceTimeout: -1 } }).success).toBe(false);
});
});
function claudeSession(setting: number) {
const [, provider, voice] = load(setting);
voice._claudeStatus = { available: true };
for (const method of [
'_showPreview',
'_startDurationTimer',
'_stopDurationTimer',
'_stopLevelMeter',
'_updateButtons',
'_hidePreview',
'_hideVoiceSendBtn',
])
voice[method] = vi.fn();
voice._insertText = vi.fn();
provider.start = vi.fn((opts) => {
provider._finalized = false;
provider._ws = { readyState: 1, send: vi.fn(), close: vi.fn() };
provider._onResult = opts.onResult;
provider._onEnd = opts.onEnd;
provider._resetSilenceTimeout();
});
voice._startClaude();
return { provider, voice };
}
describe('Claude utterances and teardown', () => {
it.each([0, 15])('keeps two final utterances through a thinking pause with setting %s', (setting) => {
const { provider, voice } = claudeSession(setting);
provider._onResult('first phrase', false);
provider._onResult('first phrase', true);
expect(voice.isRecording).toBe(true);
vi.advanceTimersByTime(2000);
provider._onResult('second phrase', false);
voice.stop();
provider._onResult('second phrase', true);
expect(voice._insertText).toHaveBeenCalledExactlyOnceWith('first phrase second phrase');
expect(voice.isRecording).toBe(false);
});
it('salvages partial text and leaves the mic idle on SSE cleanup', () => {
const { provider, voice } = claudeSession(0);
provider._onResult('keep this partial', false);
voice.cleanup();
expect(voice._insertText).toHaveBeenCalledExactlyOnceWith('keep this partial');
expect(voice.isRecording).toBe(false);
expect(voice._activeProvider).toBeNull();
expect(provider._ws).toBeNull();
expect(vi.getTimerCount()).toBe(0);
});
it('bounds finalization triggered by the provider silence timer too', () => {
const { provider, voice } = claudeSession(3);
provider._onResult('before silence', false);
vi.advanceTimersByTime(3000);
expect(provider._finalized).toBe(true);
expect(voice.isRecording).toBe(true);
vi.advanceTimersByTime(5000);
expect(voice._insertText).toHaveBeenCalledExactlyOnceWith('before silence');
expect(voice.isRecording).toBe(false);
expect(vi.getTimerCount()).toBe(0);
});
it('bounds a dead-link finalization wait and repeated taps cannot extend it', () => {
const { provider, voice } = claudeSession(0);
provider._onResult('salvage on timeout', false);
voice.stop();
vi.advanceTimersByTime(4000);
voice.stop();
expect(voice.isRecording).toBe(true);
vi.advanceTimersByTime(1000);
expect(voice._insertText).toHaveBeenCalledExactlyOnceWith('salvage on timeout');
expect(voice.isRecording).toBe(false);
expect(provider._ws).toBeNull();
expect(vi.getTimerCount()).toBe(0);
});
});
describe('empty browser recognition runs', () => {
it('backs off and stops after repeated instant empty ends instead of a tight restart loop', () => {
const voice = load(0)[2];
voice.isRecording = true;
voice._activeProvider = 'webspeech';
voice.recognition = { start: vi.fn() };
voice.stop = vi.fn();
voice._webSpeechRunStartedAt = Date.now();
voice._onWebSpeechEnd();
expect(voice.recognition.start).not.toHaveBeenCalled();
vi.advanceTimersByTime(250);
expect(voice.recognition.start).toHaveBeenCalledTimes(1);
voice._onWebSpeechEnd();
vi.advanceTimersByTime(250);
expect(voice.recognition.start).toHaveBeenCalledTimes(2);
voice._onWebSpeechEnd();
vi.advanceTimersByTime(10000);
expect(voice.recognition.start).toHaveBeenCalledTimes(2);
expect(voice.stop).toHaveBeenCalledWith(true);
});
it('resets the empty-run budget after actual speech', () => {
const voice = load(0)[2];
voice.isRecording = true;
voice._activeProvider = 'webspeech';
voice.recognition = { start: vi.fn() };
voice._showPreview = vi.fn();
voice.stop = vi.fn();
voice._webSpeechRunStartedAt = Date.now();
voice._onWebSpeechEnd();
vi.advanceTimersByTime(250);
voice._onWebSpeechResult({ results: [[{ transcript: 'a real phrase' }]] });
voice._onWebSpeechEnd();
voice._onWebSpeechEnd();
vi.advanceTimersByTime(250);
voice._onWebSpeechEnd();
vi.advanceTimersByTime(250);
expect(voice.recognition.start).toHaveBeenCalledTimes(4);
expect(voice.stop).not.toHaveBeenCalled();
expect(voice._accumulatedFinal).toBe('a real phrase');
});
it('does not restart the microphone after Stop during an empty-run backoff', () => {
const voice = load(0)[2];
voice.isRecording = true;
voice._activeProvider = 'webspeech';
for (const method of ['_stopDurationTimer', '_stopLevelMeter', '_updateButtons', '_hidePreview'])
voice[method] = vi.fn();
voice.recognition = { start: vi.fn(), stop: vi.fn() };
voice._onWebSpeechEnd();
voice.stop();
vi.advanceTimersByTime(10000);
expect(voice.recognition.start).not.toHaveBeenCalled();
expect(voice.isRecording).toBe(false);
});
function speakThenFallSilent(setting: number) {
const voice = load(setting)[2];
for (const method of ['_showPreview', '_stopDurationTimer', '_stopLevelMeter', '_updateButtons', '_hidePreview'])
voice[method] = vi.fn();
voice._insertText = vi.fn();
voice.recognition = { start: vi.fn(), stop: vi.fn(), abort: vi.fn() };
voice.isRecording = true;
voice._activeProvider = 'webspeech';
voice._webSpeechRunStartedAt = Date.now();
voice._resetSilenceTimeout();
voice._onWebSpeechResult({ results: [Object.assign([{ transcript: 'one phrase' }], { isFinal: true })] });
voice._onWebSpeechEnd();
// Each silent run lasts the engine's whole no-speech window, then backs off.
const silentRun = () => {
vi.advanceTimersByTime(6000);
voice._onWebSpeechEnd();
vi.advanceTimersByTime(250);
};
return { voice, silentRun };
}
it.each([30, 0])('keeps listening through silent 6 s runs with setting %s', (setting) => {
const { voice, silentRun } = speakThenFallSilent(setting);
for (let run = 0; run < 4; run++) silentRun();
expect(voice.isRecording).toBe(true);
expect(voice._retryCount).toBe(0);
expect(voice.recognition.start).toHaveBeenCalledTimes(5);
expect(voice._insertText).not.toHaveBeenCalled();
});
it('ends 30 s dictation at its deadline, not at the third silent run', () => {
const { voice, silentRun } = speakThenFallSilent(30);
for (let run = 0; run < 4; run++) silentRun();
// 25 s of silence so far; the deadline fires at 30 s and waits for the final.
vi.advanceTimersByTime(30000 - 4 * 6250 - 1);
expect(voice.isRecording).toBe(true);
vi.advanceTimersByTime(1);
voice._onWebSpeechEnd();
expect(voice._insertText).toHaveBeenCalledExactlyOnceWith('one phrase');
expect(voice.isRecording).toBe(false);
});
});
describe('Claude acquisition cancellation', () => {
function pendingMicrophone() {
let grant!: (value: unknown) => void;
const microphone = new Promise((resolve) => {
grant = resolve;
});
const track = { stop: vi.fn() };
const stream = { getTracks: () => [track] };
const sockets: any[] = [];
class Socket {
static OPEN = 1;
static CONNECTING = 0;
readyState = 0;
close = vi.fn();
constructor() {
sockets.push(this);
}
}
const [, provider] = load(0, {
navigator: { mediaDevices: { getUserMedia: () => microphone } },
URLSearchParams,
WebSocket: Socket,
location: { protocol: 'https:', host: 'fixture.invalid' },
window: {},
});
const onStream = vi.fn();
const onError = vi.fn();
return { provider, grant, track, stream, sockets, onStream, onError };
}
it('releases a microphone granted after cleanup without opening a socket', async () => {
const h = pendingMicrophone();
const started = h.provider.start({ onStream: h.onStream, onError: h.onError });
h.provider._cleanup();
h.grant(h.stream);
await started;
expect(h.track.stop).toHaveBeenCalledOnce();
expect(h.sockets).toHaveLength(0);
expect(h.onStream).not.toHaveBeenCalled();
expect(h.onError).not.toHaveBeenCalled();
expect(h.provider._stream).toBeNull();
});
it('cancels a connecting socket and detaches its capture callback', async () => {
const h = pendingMicrophone();
const started = h.provider.start({ onStream: h.onStream, onError: h.onError });
h.grant(h.stream);
await started;
const socket = h.sockets[0];
expect(socket.onopen).toBeTypeOf('function');
h.provider._cleanup();
expect(socket.onopen).toBeNull();
expect(socket.close).toHaveBeenCalledOnce();
expect(h.track.stop).toHaveBeenCalledOnce();
expect(h.provider._ws).toBeNull();
});
});