Compare commits

...
23 Commits
Author SHA1 Message Date
branderson 31ffe8564b Merge pull request 'A session switcher that scales past a dozen sessions' (#405) from session-switcher into development
Reviewed-on: #405
Reviewed-by: branderson <brad@branderson.io>
2026-10-16 17:48:42 -07:00
branderson cf8b3ee20f Merge remote-tracking branch 'origin/development' into session-switcher
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / confined (mirror) (pull_request_target) Successful in 1m18s
tests / confined (proxy) (pull_request_target) Successful in 1m31s
tests / confined (providers) (pull_request_target) Successful in 2m14s
tests / confined (launch) (pull_request_target) Successful in 2m38s
tests / confined (share) (pull_request_target) Successful in 2m26s
tests / runner-env (confined) (pull_request_target) Successful in 1m10s
tests / confined (shellcheck) (pull_request_target) Successful in 1m42s
tests / confined (audit) (pull_request_target) Successful in 4m23s
tests / confined (remote) (pull_request_target) Successful in 4m21s
tests / confined (project) (pull_request_target) Successful in 5m9s
tests / confined (static) (pull_request_target) Successful in 4m14s
tests / privileged (pull_request_target) Successful in 6m1s
tests / confined (config) (pull_request_target) Successful in 6m11s
tests / runner-env (privileged) (pull_request_target) Successful in 1m3s
2026-10-16 17:22:09 -07:00
branderson 91befe91f1 Merge pull request 'Count every state in one pass, and say where the states live' (#412) from attach-counts into session-switcher
tests / confined (mirror) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m16s
tests / confined (proxy) (pull_request_target) Successful in 1m29s
tests / confined (providers) (pull_request_target) Successful in 2m14s
tests / confined (launch) (pull_request_target) Successful in 2m39s
tests / confined (share) (pull_request_target) Successful in 2m27s
tests / runner-env (confined) (pull_request_target) Successful in 1m11s
tests / confined (shellcheck) (pull_request_target) Successful in 1m43s
tests / confined (audit) (pull_request_target) Successful in 4m24s
tests / confined (remote) (pull_request_target) Successful in 4m24s
tests / confined (project) (pull_request_target) Successful in 5m11s
tests / confined (static) (pull_request_target) Successful in 4m19s
tests / privileged (pull_request_target) Successful in 6m12s
tests / confined (config) (pull_request_target) Successful in 6m14s
tests / runner-env (privileged) (pull_request_target) Successful in 1m5s
Reviewed-on: #412
Reviewed-by: branderson <brad@branderson.io>
2026-10-16 15:23:40 -07:00
branderson ab74531b63 Call the state waiting, which is what everything else already called it
tests / confined (mirror) (pull_request_target) Successful in 1m16s
tests / confined (conversation) (pull_request_target) Successful in 1m21s
tests / confined (proxy) (pull_request_target) Successful in 1m29s
tests / confined (providers) (pull_request_target) Successful in 2m15s
tests / confined (launch) (pull_request_target) Successful in 2m38s
tests / confined (share) (pull_request_target) Successful in 2m27s
tests / runner-env (confined) (pull_request_target) Successful in 1m11s
tests / confined (shellcheck) (pull_request_target) Successful in 1m42s
tests / confined (audit) (pull_request_target) Successful in 4m26s
tests / confined (remote) (pull_request_target) Successful in 4m25s
tests / confined (static) (pull_request_target) Successful in 4m14s
tests / confined (project) (pull_request_target) Successful in 5m11s
tests / privileged (pull_request_target) Successful in 6m7s
tests / confined (config) (pull_request_target) Successful in 6m14s
tests / runner-env (privileged) (pull_request_target) Successful in 1m4s
The hook's event is `waiting`, the flag is --count-waiting, and the picker
said needs-you: three names for one thing, and the status line is where that
stops being an opinion - `waiting=1 working=0` is about as short as a status
can get and still say what it is.

One word now, in the picker as well as in --counts, so a number on a status
bar and a row on a screen still say the same thing. The fold from the two
harness events onto it is said once at each site that does it, because
`done|waiting) state=waiting` reads as a tautology without knowing that the
left side is an event name.
2026-10-16 13:03:12 -07:00
branderson 0e4717d7ed Count every state in one pass, and say where the states live
tests / runner-env (confined) (pull_request_target) Successful in 1m11s
tests / confined (shellcheck) (pull_request_target) Successful in 1m42s
tests / confined (static) (pull_request_target) Successful in 4m24s
tests / privileged (pull_request_target) Successful in 6m1s
tests / confined (config) (pull_request_target) Successful in 6m23s
tests / runner-env (privileged) (pull_request_target) Successful in 1m3s
tests / confined (share) (pull_request_target) Successful in 2m30s
tests / confined (audit) (pull_request_target) Successful in 4m29s
tests / confined (remote) (pull_request_target) Successful in 4m31s
tests / confined (project) (pull_request_target) Successful in 5m17s
tests / confined (mirror) (pull_request_target) Successful in 1m16s
tests / confined (conversation) (pull_request_target) Successful in 1m24s
tests / confined (proxy) (pull_request_target) Failing after 1m31s
tests / confined (providers) (pull_request_target) Successful in 2m18s
tests / confined (launch) (pull_request_target) Successful in 2m41s
A status bar wanting both numbers paid for two invocations of the one thing
here that runs on a timer. --counts answers for all of them in the pass the
counter already makes, in the picker's words, so a number on a status bar and
a row on a screen say the same thing about the same session.

--state-dir is for a reader that is not dejima: the directory is under
XDG_RUNTIME_DIR where there is one and under /tmp where there is not, so an
outside tool either asks or re-derives that.
2026-10-16 00:31:14 -07:00
branderson 7da90f51e1 Merge pull request 'Reach the picker on a new socket, not a new page' (#411) from web-reconnect into session-switcher
tests / confined (mirror) (pull_request_target) Successful in 1m15s
tests / confined (proxy) (pull_request_target) Successful in 1m30s
tests / confined (providers) (pull_request_target) Successful in 2m15s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / confined (launch) (pull_request_target) Successful in 2m42s
tests / confined (share) (pull_request_target) Successful in 2m29s
tests / confined (shellcheck) (pull_request_target) Successful in 1m46s
tests / runner-env (confined) (pull_request_target) Successful in 1m13s
tests / confined (remote) (pull_request_target) Successful in 4m34s
tests / confined (project) (pull_request_target) Successful in 5m21s
tests / privileged (pull_request_target) Successful in 6m0s
tests / confined (audit) (pull_request_target) Successful in 4m35s
tests / confined (static) (pull_request_target) Successful in 4m23s
tests / confined (config) (pull_request_target) Successful in 6m24s
tests / runner-env (privileged) (pull_request_target) Successful in 1m1s
Reviewed-on: #411
Reviewed-by: branderson <brad@branderson.io>
2026-10-16 00:03:51 -07:00
branderson 117622e144 Resolve Playwright where it is, and name the check in the release pass
tests / confined (conversation) (pull_request_target) Successful in 1m16s
tests / confined (mirror) (pull_request_target) Successful in 1m16s
tests / confined (proxy) (pull_request_target) Successful in 1m31s
tests / confined (providers) (pull_request_target) Successful in 2m17s
tests / confined (launch) (pull_request_target) Successful in 2m44s
tests / confined (share) (pull_request_target) Successful in 2m28s
tests / confined (shellcheck) (pull_request_target) Successful in 1m42s
tests / confined (audit) (pull_request_target) Successful in 4m22s
tests / confined (static) (pull_request_target) Successful in 4m13s
tests / confined (project) (pull_request_target) Successful in 5m11s
tests / runner-env (privileged) (pull_request_target) Successful in 1m5s
tests / runner-env (confined) (pull_request_target) Successful in 1m9s
tests / confined (remote) (pull_request_target) Successful in 4m23s
tests / privileged (pull_request_target) Successful in 6m6s
tests / confined (config) (pull_request_target) Successful in 6m13s
The import was an absolute path into the dejima image, so the command the
README gives worked in a sandbox and nowhere else. It tries the module path
first and says what to install when neither answers.

Nothing runs this check on a schedule, which is the cost of it living outside
npm test: docs/release.md's manual list is where this project already parks
checks that need a machine CI does not have, so a release touching web/ names
it.
2026-10-15 23:17:58 -07:00
branderson a26c10e8c6 Reach the picker on a new socket, not a new page
tests / confined (mirror) (pull_request_target) Successful in 1m18s
tests / confined (proxy) (pull_request_target) Successful in 1m33s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / confined (providers) (pull_request_target) Successful in 2m20s
tests / confined (launch) (pull_request_target) Successful in 2m43s
tests / confined (share) (pull_request_target) Successful in 2m27s
tests / confined (audit) (pull_request_target) Successful in 4m24s
tests / runner-env (confined) (pull_request_target) Successful in 1m12s
tests / confined (shellcheck) (pull_request_target) Successful in 1m40s
tests / confined (remote) (pull_request_target) Successful in 4m21s
tests / confined (project) (pull_request_target) Successful in 5m14s
tests / confined (config) (pull_request_target) Successful in 6m14s
tests / privileged (pull_request_target) Successful in 6m39s
tests / runner-env (privileged) (pull_request_target) Successful in 1m5s
tests / confined (static) (pull_request_target) Successful in 4m18s
ttyd takes the run id as a WebSocket argument rather than as part of the page,
so pressing SESS only has to open a different socket. It reloaded the page
instead: half a megabyte of inlined terminal fetched, parsed and run again to
change one command, which on a phone was most of the wait between the key and
the picker. Measured in Chromium over loopback, where there is no network and
the CPU is a desktop's: 406ms before, 195ms after.

The old socket's handlers are detached before it is closed rather than a flag
being read inside them - onclose is asynchronous, so a handler left attached
can arrive after the replacement socket exists, null the field holding it, and
schedule a reconnect to the url that was just replaced.

The terminal is reset on the way, and the mode tracker with it: what the
socket carries away is the process, not the alternate screen or the mouse
reporting it left switched on in here.

web/test/browser/ is a mock ttyd and a Chromium check, outside npm test
because CI has no browser. It is what shows the page is neither fetched nor
re-run, which no unit test can see.
2026-10-15 22:16:27 -07:00
branderson 65caf4577b Say what a listing costs now, where the docs said one call per container
tests / confined (conversation) (pull_request_target) Successful in 1m15s
tests / confined (mirror) (pull_request_target) Successful in 1m39s
tests / confined (proxy) (pull_request_target) Successful in 1m48s
tests / confined (providers) (pull_request_target) Successful in 2m36s
tests / confined (launch) (pull_request_target) Successful in 2m51s
tests / confined (share) (pull_request_target) Successful in 2m22s
tests / confined (shellcheck) (pull_request_target) Successful in 1m57s
tests / confined (audit) (pull_request_target) Successful in 4m13s
tests / runner-env (confined) (pull_request_target) Successful in 1m9s
tests / confined (project) (pull_request_target) Successful in 5m3s
tests / confined (static) (pull_request_target) Successful in 4m4s
tests / privileged (pull_request_target) Successful in 6m17s
tests / runner-env (privileged) (pull_request_target) Successful in 1m3s
tests / confined (remote) (pull_request_target) Successful in 4m31s
tests / confined (config) (pull_request_target) Successful in 6m10s
2026-10-15 19:38:14 -07:00
branderson 21eeaa5bf0 Ask docker and tmux once each for a listing, not once per session
A listing of eighteen sessions took 2.3 seconds before the picker drew, and
the web front door reaches it through a full page load, so that is the wait
after the SESS key. Measured against an eighteen-session fixture with a stub
answering in 60ms, which is what a local daemon costs: 2259ms before, 468ms
after.

Three things, in the order they cost:

docker inspect ran once per container. It runs once for all of them, with both
values as JSON so that one line per container is a property rather than a
hope - a newline in an environment value is escaped rather than printed, and a
container that went away takes its own line with it instead of shifting the
rest.

tmux list-panes ran once per session, through a grep. It runs once per listing
and the membership test is a case.

container_for, session_level, state_color and age_short were each called in a
command substitution per row, which forks. They answer in a variable, the way
tui_pick already does.
2026-10-15 19:20:35 -07:00
branderson d279c93e13 Fit the name column to the terminal, and mark what it cuts
tests / confined (mirror) (pull_request_target) Successful in 1m16s
tests / confined (conversation) (pull_request_target) Successful in 1m24s
tests / confined (project) (pull_request_target) Successful in 5m16s
tests / confined (proxy) (pull_request_target) Successful in 1m32s
tests / confined (providers) (pull_request_target) Successful in 2m17s
tests / confined (launch) (pull_request_target) Successful in 2m41s
tests / runner-env (confined) (pull_request_target) Successful in 1m10s
tests / confined (share) (pull_request_target) Successful in 2m28s
tests / confined (shellcheck) (pull_request_target) Successful in 1m43s
tests / confined (audit) (pull_request_target) Successful in 4m27s
tests / confined (remote) (pull_request_target) Successful in 4m27s
tests / confined (static) (pull_request_target) Successful in 4m17s
tests / privileged (pull_request_target) Successful in 6m14s
tests / runner-env (privileged) (pull_request_target) Successful in 1m4s
tests / confined (config) (pull_request_target) Successful in 6m19s
A fixed width is wrong in both directions at once: a desktop has room for
every name and would cut them anyway, and a phone narrower than the guess
still overflows and wraps, which is the failure this layout exists to avoid.

The width is asked of the controlling terminal rather than of stdin, which is
where `tput cols` reads it and is a pipe wherever a choice is fed in rather
than typed; a pty nobody set a size on reports zero columns, which is an
unanswered question and not a width. A cut name ends in a `~`, in ASCII where
the rest of the glyphs are not, because this one sits inside a padded field and
bash measures a multi-byte character as its bytes wherever the locale is not
UTF-8 - which would take the state column two places left on that row alone.
2026-10-15 16:00:29 -07:00
branderson 439b9b2d8b Document the picker's three columns and the starting state
tests / confined (mirror) (pull_request_target) Successful in 1m15s
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / confined (proxy) (pull_request_target) Successful in 1m32s
tests / confined (providers) (pull_request_target) Successful in 2m15s
tests / confined (launch) (pull_request_target) Successful in 2m43s
tests / confined (share) (pull_request_target) Successful in 2m28s
tests / runner-env (confined) (pull_request_target) Successful in 1m10s
tests / confined (shellcheck) (pull_request_target) Successful in 1m39s
tests / confined (static) (pull_request_target) Successful in 4m14s
tests / confined (audit) (pull_request_target) Successful in 4m26s
tests / confined (remote) (pull_request_target) Successful in 4m23s
tests / confined (project) (pull_request_target) Successful in 5m9s
tests / privileged (pull_request_target) Successful in 6m10s
tests / confined (config) (pull_request_target) Successful in 6m12s
tests / runner-env (privileged) (pull_request_target) Successful in 1m4s
2026-10-15 15:30:42 -07:00
branderson 6dcefc127d Cut the picker's row down to what a phone can read
Five columns made a row about eighty characters wide. On a phone it wrapped,
and the wrapped half - the state and the age - landed under the NEXT session's
number, which is the opposite of what a numbered list is for.

Three columns now. The name comes first, against the index, because those two
are what an operator matches up. The tmux label and the container id stay in
--list: the label is a coordinate nobody has to act on now that picking a
session goes to it, and every container id shares a prefix with every other,
so a column of them said nothing. The one answer that column carried that was
not decoration - a session whose image is still building - moves into the
state, where it is read.

The age is minutes for an hour, then hours, then days: three characters, and
not a sum to do first.
2026-10-15 15:29:55 -07:00
branderson aff7f511e8 Stop an empty field shifting every column after it in the picker
A tab is IFS whitespace, so a run of them collapses into one delimiter. An
entry written before the name field existed has an empty one, and the picker
then read the tmux label as the name, the state as the age, and the container
id as the state - so a stale session was shown neither in red nor with the word
stale anywhere a reader would look for it.

Every session on a host that upgraded mid-flight has such an entry, which is
every session until it is relaunched.

The rows stay tab-separated, because that is what --list promises; the picker
converts them to a separator that is not whitespace before reading.
2026-10-15 15:25:37 -07:00
branderson 44ac1e6216 Default the session name to the project, and suffix a collision
tests / confined (launch) (pull_request_target) Successful in 2m46s
tests / confined (shellcheck) (pull_request_target) Successful in 1m41s
tests / confined (audit) (pull_request_target) Successful in 4m33s
tests / confined (remote) (pull_request_target) Successful in 4m29s
tests / confined (static) (pull_request_target) Successful in 4m19s
tests / privileged (pull_request_target) Successful in 5m56s
tests / runner-env (privileged) (pull_request_target) Successful in 59s
tests / confined (config) (pull_request_target) Successful in 6m44s
tests / confined (mirror) (pull_request_target) Successful in 1m19s
tests / confined (proxy) (pull_request_target) Successful in 1m36s
tests / confined (conversation) (pull_request_target) Successful in 1m29s
tests / confined (providers) (pull_request_target) Successful in 2m21s
tests / confined (share) (pull_request_target) Successful in 2m28s
tests / runner-env (confined) (pull_request_target) Successful in 1m12s
tests / confined (project) (pull_request_target) Successful in 5m20s
Most launches pass no --name, so the default is what this mostly is: the
project directory, which is what both front doors showed before.

A name already in use takes the first free -N rather than refusing the launch -
several sessions against one project is the case the name exists for, so
stopping over a collision would be a worse answer than two rows that agree.
The first free one, so a number a finished session gives back is used again
instead of the count climbing for the life of the host. Collisions come from
the live registry and the tmux window list, because each is blind to a case the
other sees, and this launch's own window is not a conflict with itself.

A derived name is cut down where an explicit one is refused. The difference is
who chose it: a directory was not named for this, so a launch that stopped
because a path was long would be refusing over something it can fix.
2026-10-15 14:23:40 -07:00
branderson fc34c165df Document the session name, the states and the live view
docs/remote-access.md's Attaching section is where the session list is
described, so the two columns that changed and the two new modes belong there.
The README gets the half that applies with remote access off: the tmux window
name.
2026-10-15 14:23:40 -07:00
branderson 5f13b4015b Add a live view and a count for the status line
--watch is the picker redrawn on a timer: which sessions are working and which
are waiting, in one window, with the same numbers to go to one. Deliberately
not a second tool - a monitor would grow its own list, its own columns and its
own idea of what a session is, and what an operator does after seeing which
session wants them is to go to it.

--count-waiting is the same question reduced to one number, for a tmux status
line that re-runs it every few seconds. It reads the registry, the pane list
and the levels and asks docker nothing: the point of the number is not having
to open anything, so it must not cost a round trip per container.

tui_pick grew an optional timeout rather than the caller growing its own read,
so the live list and the static one share one prompt and one range check.
2026-10-15 14:23:13 -07:00
branderson f99868fb7c Ask docker once per listing instead of once per session
container_for ran `docker ps` and then inspected containers one at a time
until it found its match, and it was called for every entry in the registry:
quadratic in the number of sessions, on the listing both front doors open on.
Twelve sessions cost twelve `docker ps` calls and up to a hundred and
forty-four inspects.

The map is built once and looked up. What keeps "no such container" apart from
"couldn't ask" is now a flag on the map rather than a local, with the same
reach: one unanswerable inspect anywhere makes every miss unverified, because
pruning on that answer unregisters a live session for good.
2026-10-15 14:23:13 -07:00
branderson f5f228f7ae Switch the client when the picker is already inside tmux
Everything in attach() is written for a client with no tmux of its own, a
phone or an ssh forced command, and tmux refuses to nest a session inside one
of its own panes - so on the operator's own machine the picker could list every
session and then reach none of them.

switch-client rather than select-window, so a sandbox launched from a different
tmux session is reachable too. Nothing to create, verify or reap on this path,
and the window landed on is the real one.
2026-10-15 14:23:13 -07:00
branderson 3eacfd26e8 Say in the listing which sessions want attention
Every row said "attachable", which is true of all of them. The column now
carries the state the session reported when there is one - needs-you, working -
and the reachability answer when there is not, because a session that cannot be
attached to has nothing useful to say about whether it wants attention and the
two sets of words do not overlap.

Oldest first, by start time and deliberately not by state: the numbers are how
a session is picked, so an order that followed the state would renumber the
rows between an operator reading one and typing it.
2026-10-15 14:23:13 -07:00
branderson 1a4ba0d192 Have a session report which state it is in, not only that it changed
The marker a session writes is consumed by the watcher that pushes it, so
after a notification there is nothing left to read - and with no watcher
running, nothing has read it at all. Neither front door can answer "which of
these sessions is waiting for me" from that.

The hook now also keeps one file in a level/ directory the host creates: the
state the session is in, overwritten rather than consumed. Still a name and no
content, still only ever about the session writing it. UserPromptSubmit
supplies the other half and pushes nothing, because the edge markers keep
their two-name enum.
2026-10-15 14:23:13 -07:00
branderson b12b303780 Name the tmux window the launch is sitting in
choose-window, the status line and the window list all show a window name, and
for a dozen sandboxes they showed a dozen copies of whatever automatic-rename
made of the container command. Nothing in tmux has to know dejima exists for
this to be the fix, which is why it is worth more than a picker column.

A window the operator named by hand is left alone: tmux turns automatic-rename
off on rename, so the option still being on is the evidence that the name is
tmux's guess. The option goes back on at exit, or the window keeps the name of
a session that has ended.
2026-10-15 14:23:12 -07:00
branderson 79853138fb Let a launch say what the session is for
Both front doors identify a session by the basename of its workspace, which
answers "which project" and nothing else. The case that needs answering is a
dozen sessions against one project, where every row in the picker reads the
same.

--name takes the operator's own words into the registry and the listing. The
picker spends the project's column on it rather than adding one, because that
column's width is what wraps a row on a phone.
2026-10-15 14:23:12 -07:00
19 changed files with 1947 additions and 95 deletions
+36
View File
@@ -25,6 +25,36 @@ requests, which are linked.
The release pull request that merges `development` into `main` replaces this
heading with the date it merges, and tags `main` with the same date.
### New
- **Every session has a name**, in both front doors' listings and as the tmux
window name, so `choose-window` and the status line can tell a dozen
sessions of one project apart. It is the project directory by default and
`dejima --name <text>` when you choose one; a name already in use takes the
first free `-N` rather than refusing the launch.
- **The session list says which sessions want you.** `waiting` and `working`
where every row used to say `attachable`, from the session's own report
rather than from the notification markers, so it works with notifications
switched off.
- **The picker is three columns** - name, state, age - where it was five. On a
phone the old row wrapped and put half of each session under the next
session's number. The tmux label and the container id stay in `--list`.
- **`dejima-attach --watch`**, the picker redrawn on a timer: one window for
which sessions are working and which are waiting.
- **`dejima-attach --count-waiting`** for a tmux status line - the same
question as one number, and the one path that asks Docker nothing.
**`--counts`** answers for every state in the same pass, so two numbers on a
status bar cost one invocation rather than two, and **`--state-dir`** says
where the registry and the reported states are for a reader that is not
dejima.
- **Inside tmux the picker switches your client** to the session's own window
instead of attaching. tmux refuses to nest, so on your own machine the
picker could list every session and reach none of them.
- **The web front door's SESS button no longer reloads the page.** It opens a
new WebSocket instead, which is all ttyd needs to run a different command,
so the half a megabyte of inlined terminal is not fetched and re-run to
reach the picker. On a phone that was most of the wait.
### Changed
- **The GitHub mirrors carry a rewritten history.** AI co-author trailers are
@@ -34,6 +64,12 @@ heading with the date it merges, and tags `main` with the same date.
### Fixed
- **The session listing is five times faster on a host running a dozen
sessions**, which is the wait between pressing SESS on the web front door
and the picker drawing. It asked Docker once per session and tmux once per
session, and forked a subshell per row for each of four lookups; it now asks
Docker twice and tmux once for the whole listing, whatever that holds.
Measured against an eighteen-session fixture: 2259ms before, 468ms after.
- **The credential proxy refuses an oversized request body before inviting
it.** It answered `100 Continue` and only then read the declared length, so
whether a session saw the `413` or only the `100` was timing. The cap itself
+3 -1
View File
@@ -320,7 +320,9 @@ table of what differs, and what the mode gives up.
session. It gets your terminal back rather than pausing the agent - the
container keeps running - so for a session you want to leave working and pick
up elsewhere, run dejima inside tmux and use
[`dejima-attach`](docs/remote-access.md).
[`dejima-attach`](docs/remote-access.md). Every session names its tmux window
after the project, or after `--name <text>`, with a `-2` on the second one -
which is what tells a dozen parallel sessions of one project apart.
→ [why that takes doing](docs/configuration.md#suspending-a-session)
## Sign in on the host first
+179 -3
View File
@@ -57,8 +57,8 @@ Options (each overrides the config file for one launch only):
--workflow <name> what the agent is told to do with its work
--cmd <command> run this instead of the harness's own command
--identity <name> which [identity.<name>] supplies the credentials
-i, --include <tool> add an optional tool to this launch (repeatable);
see docs/tools.md for what dejima ships
-i, --include <tool> add an optional tool (repeatable); see docs/tools.md
--name <text> what this session is for, in the picker and in tmux
--backend <name> the selected harness's model API, model and context
--model <id> budget, each refused for a harness with no such
--context-window <n> setting
@@ -284,10 +284,140 @@ NO_PAUSE=0
# file that silently decided it would make the next launch's answer to "where
# is my work" depend on a line nobody reread.
DEJIMA_ISOLATED=0
# What this session is for, in the operator's own words. Both front doors
# identify a session by the basename of its workspace, which answers "which
# project" and nothing else - and the case that needs answering is a dozen
# sessions against ONE project, where every row reads the same. Also becomes
# the tmux window name, which is what makes `choose-window` and the status
# line legible without either of them knowing dejima exists.
#
# A flag and no config key: a name shared by every launch of a project is the
# ambiguity this exists to remove, so a file is the one place it cannot
# usefully live.
DEJIMA_SESSION_NAME=""
# What _session_label() settled on, which is what both the tmux window and the
# registry entry get. Bound here as well so that a path reaching the registry
# without having passed the assignment would write an empty name rather than
# abort under set -u.
DEJIMA_SESSION_LABEL=""
# The pane whose window this launch renamed, for cleanup() to hand back. Bound
# here rather than beside the rename itself, which is below `trap cleanup
# EXIT`: the trap dereferences this, and every name it dereferences has to be
# bound before the trap can fire.
DEJIMA_TMUX_RENAMED=""
# Whether the operator wrote `--`, so an isolated launch can tell a project
# path it must refuse from harness arguments it must pass through. Both arrive
# here as positionals once the loop breaks.
DEJIMA_ARGS_ONLY=0
# Refused rather than sanitized, like the workspace and tmux label this sits
# beside in the registry. A newline would add a key=value line to a flat entry
# that dejima-attach parses last-match-wins, and a tab would move a column in
# the listing that splits on them; the rest are control characters heading for
# a terminal and a tmux window name. The cap is the picker: it pads every
# column to the widest value, so one long name indents all of them off the
# right edge of a phone.
_set_session_name() {
case "$1" in
*[[:cntrl:]]*)
echo "--name must not contain a newline, a tab or any other control character" >&2
exit 1 ;;
esac
if [ "${#1}" -gt 32 ]; then
echo "--name is ${#1} characters; 32 is the most the session picker can show" >&2
exit 1
fi
DEJIMA_SESSION_NAME="$1"
}
# Every session label already in use, one per line. Two sources, because each
# is blind to a case the other sees: a session started outside tmux has no
# window, and a launch with remote access off writes no registry entry.
#
# This launch's own window is excluded. It may already carry the name - from
# automatic-rename having guessed the directory, or from a previous session in
# the same window - and a window conflicting with itself would push every
# launch to a suffix.
_taken_session_labels() {
local dir file pid name self=""
if [ -n "${TMUX_PANE:-}" ] && command -v tmux >/dev/null 2>&1; then
self="$(tmux display-message -p -t "$TMUX_PANE" '#{window_id}' 2>/dev/null || true)"
tmux list-windows -a -F '#{window_id} #{window_name}' 2>/dev/null |
while read -r id name; do
[ "$id" = "$self" ] || printf '%s\n' "$name"
done
fi
dir="$(remote_state_dir)/sessions"
[ -d "$dir" ] || return 0
for file in "$dir"/*; do
[ -f "$file" ] || continue
# Parsed, never sourced, like every other read of these files.
name="$(sed -n 's/^name=//p' "$file" | head -1)"
[ -n "$name" ] || continue
pid="$(sed -n 's/^pid=//p' "$file" | head -1)"
# An entry whose launcher is gone is holding no name: the exit trap
# removes the entry, but a session killed outright leaves it behind,
# and the suffixes would then climb past names nothing is using. An
# entry with no pid= at all predates the field and is trusted.
case "$pid" in
''|*[!0-9]*) ;;
*) kill -0 "$pid" 2>/dev/null || continue ;;
esac
printf '%s\n' "$name"
done
}
# The name this session is known by, decided once and used for both the tmux
# window and the registry entry: --name when the operator typed one, the
# project directory otherwise.
#
# A derived name that is already in use gets the first free `-N` rather than
# refusing the launch. Several sessions against one project is the ordinary
# case here - it is the case the name exists for - so a launch that stopped
# over a collision would be a worse answer than two rows that agree. The
# FIRST free suffix, so a number freed by a session ending is used again
# instead of the count climbing for the life of the host.
#
# Nothing reserves the name, so two launches in the same instant can still
# land on one. The window is small, and what it costs is a pair of rows that
# read alike - which is where this started, not a failure.
#
# An explicit --name is taken verbatim, collision or not. The operator typed
# it for this launch, and quietly handing back something else is worse than
# the ambiguity it would be avoiding.
_session_label() {
local base taken candidate n
if [ -n "$DEJIMA_SESSION_NAME" ]; then
printf '%s' "$DEJIMA_SESSION_NAME"
return 0
fi
if [ "$DEJIMA_ISOLATED" = 1 ]; then
# What the registry records for this mode anyway: the workspace is
# dejima's own scratch path, so its basename names nothing.
base=isolated
else
base="$(basename "$WORKSPACE_DIR")"
fi
# Stripped and truncated where _set_session_name refuses. The difference is
# who chose the value: a directory name was not chosen for this, and a
# launch that stopped because a path was long or held an odd byte would be
# refusing over something it can fix itself.
base="${base//[[:cntrl:]]/}"
[ -n "$base" ] || base=session
base="${base:0:32}"
taken="$(_taken_session_labels)"
candidate="$base"
n=1
# Bounded, so a host with a hundred sessions of one project cannot spin
# here. Past the bound the bare name is used, which is the same outcome as
# the race above.
while [ "$n" -lt 100 ] && grep -qxF -- "$candidate" <<< "$taken"; do
n=$((n + 1))
# Room for the suffix, measured rather than assumed: a project at the
# cap leaves none.
candidate="${base:0:$(( 32 - ${#n} - 1 ))}-$n"
done
printf '%s' "$candidate"
}
while [ $# -gt 0 ]; do
case "$1" in
--) DEJIMA_ARGS_ONLY=1; shift; break ;;
@@ -308,6 +438,7 @@ while [ $# -gt 0 ]; do
# for the launch they run by hand tomorrow.
--no-pause) NO_PAUSE=1; shift ;;
--isolated) DEJIMA_ISOLATED=1; shift ;;
--name) _need_value --name "${2:-}"; _set_session_name "$2"; shift 2 ;;
--no-log) _set_override logging.loki_url ""
_set_override logging.conversation off
NO_LOG=1; shift ;;
@@ -1804,6 +1935,14 @@ cleanup() {
# run that was killed rather than exited put neither back.
printf '\033[?25h' > /dev/tty 2>/dev/null || true
fi
# Hand the window's name back to tmux. rename-window turned
# automatic-rename off for it, so without this the window keeps this
# session's name for whatever the operator does in it next - and the name
# of a sandbox that has exited is worse than `docker` was, because it reads
# as a session still there.
if [ -n "$DEJIMA_TMUX_RENAMED" ]; then
tmux set-option -w -t "$DEJIMA_TMUX_RENAMED" automatic-rename on 2>/dev/null || true
fi
if [ -n "${SANDBOX_TTY_SUSPEND_PID:-}" ]; then
kill "$SANDBOX_TTY_SUSPEND_PID" 2>/dev/null || true
wait "$SANDBOX_TTY_SUSPEND_PID" 2>/dev/null || true
@@ -2023,6 +2162,31 @@ cleanup() {
}
trap cleanup EXIT
# Name the tmux window this launch is sitting in, so that tmux's own
# navigation is legible: `choose-window`, the status line and the window list
# all show a window name, and what they showed for a dozen sandboxes was a
# dozen copies of whatever automatic-rename made of `docker`. Nothing in tmux
# has to know dejima exists for this to work, which is why it is worth more
# than any picker.
#
# --name when given, the project otherwise. An operator who renamed the window
# by hand is left alone: tmux turns automatic-rename off for a window on
# rename, so the option still being on is the evidence that the current name is
# tmux's guess rather than their choice. An explicit --name overrides that,
# because it was typed for this launch.
#
# Installed below the trap so the restore in cleanup() is already armed, and
# every call is guarded: a tmux that refuses must not end a launch.
DEJIMA_SESSION_LABEL="$(_session_label)"
if [ -n "${TMUX_PANE:-}" ] && command -v tmux >/dev/null 2>&1; then
if [ -n "$DEJIMA_SESSION_NAME" ] ||
[ "$(tmux display-message -p -t "$TMUX_PANE" '#{automatic-rename}' 2>/dev/null || true)" = 1 ]; then
if tmux rename-window -t "$TMUX_PANE" "$DEJIMA_SESSION_LABEL" 2>/dev/null; then
DEJIMA_TMUX_RENAMED="$TMUX_PANE"
fi
fi
fi
# Git commits inside the sandbox need a user.name/user.email - carry over a
# copy of the host's global gitconfig, read-write: entrypoint.sh's
# `gh auth setup-git` (what makes GitHub read-only from in here - see
@@ -3461,8 +3625,13 @@ if remote_enabled; then
mkdir -p -m 700 "$DEJIMA_STATE_DIR/sessions" "$DEJIMA_STATE_DIR/notify"
DEJIMA_SESSION_FILE="$DEJIMA_STATE_DIR/sessions/$DEJIMA_RUN_ID"
DEJIMA_NOTIFY_DIR="$DEJIMA_STATE_DIR/notify/$DEJIMA_RUN_ID"
# level/ is where the hook records which state the session is in, as
# opposed to the edge markers beside it that the watcher consumes. Created
# here rather than by the hook for the same reason the parent is: the host
# owns this tree's shape, and a session that could create the directory
# could create it as something else.
# shellcheck disable=SC2174
mkdir -p -m 700 "$DEJIMA_NOTIFY_DIR"
mkdir -p -m 700 "$DEJIMA_NOTIFY_DIR" "$DEJIMA_NOTIFY_DIR/level"
# Verify what was just created, because everything downstream assumes it.
# A leaf left behind root-owned by an earlier container restart (see
@@ -3541,6 +3710,13 @@ if remote_enabled; then
# startup banner makes, and for the same reason. What tells two
# concurrent isolated sessions apart is the run id beside it, which
# that listing already prints.
# First of the lines, so that a value carrying a newline would land
# ahead of every real key rather than after it: the parser is
# last-match-wins, so an injected `pane=` here loses to the one below.
# _set_session_name refuses the newline outright, which is what makes
# that independent of this ordering; both, because the field is the
# only one an operator types.
printf 'name=%s\n' "$DEJIMA_SESSION_LABEL"
if [ "$DEJIMA_ISOLATED" = 1 ]; then
printf 'workspace=isolated\n'
else
+380 -61
View File
@@ -19,12 +19,21 @@ case "${1:-}" in
cat <<'DEJIMA_ATTACH_HELP'
dejima-attach - list live dejima sessions and attach to one
Usage: dejima-attach [run-id | --list]
Usage: dejima-attach [run-id | --watch | --list | --count-waiting]
(no argument) numbered picker of live sessions; attaches to the choice
run-id attach to one directly, which is what a notification's deep
link and ttyd's --url-arg both produce
--watch the same picker, redrawn every few seconds: which sessions
are working and which are waiting for you, in one window
--list one tab-separated row per live session, for scripting
--count-waiting how many are waiting for you, for a tmux status line
--counts one line of key=value for all of them, counted in one pass
--state-dir where the registry and the reported states live
Inside tmux the picker switches this client to the session's own window
instead of attaching, so it is a window switcher there and a front door
everywhere else.
This is the shared core behind both remote front doors: it runs as a forced SSH
command and as ttyd's command, so what an SSH key can reach and what the web
@@ -72,16 +81,42 @@ source "$SCRIPT_DIR/dejima-tui-lib.sh"
# this - see tui_clear.
banner() { tui_clear; tui_banner 'live sessions'; }
# Coloured by meaning, not by name: attachable is the one an operator wants to
# find, and stale/no-tmux are the two that will refuse if picked.
# Coloured by meaning, not by name: waiting is the one an operator is looking
# for, working is the one they can leave alone, and stale/no-tmux are the two
# that will refuse if picked.
STATE_COLOR=""
state_color() {
case "$1" in
attachable) printf '%s' "$C_OK" ;;
stale) printf '%s' "$C_ERR" ;;
*) printf '%s' "$C_WARN" ;;
working|attachable) STATE_COLOR="$C_OK" ;;
stale) STATE_COLOR="$C_ERR" ;;
*) STATE_COLOR="$C_WARN" ;;
esac
}
# What the session last said about itself, from the level directory
# hooks/dejima-notify.sh maintains. Empty for a session that has said nothing
# yet, for a harness that reports nothing, and for a launch from before the
# directory existed - all three render as the plain reachability state.
#
# The enum is iterated rather than the directory read, so a name the session
# invented is never looked at. `done` comes before `running` deliberately: the
# hook clears the siblings before writing, so holding both means a session has
# contradicted itself, and the answer that sends an operator to look is the
# safe one. -L as well as -e because a dangling symlink is a file a session can
# plant as easily as an empty one, and it is still the session reporting on
# itself either way.
SESSION_LEVEL=""
session_level() {
local dir="$STATE_DIR/notify/$1/level" event
SESSION_LEVEL=""
for event in 'done' waiting running; do
if [ -e "$dir/$event" ] || [ -L "$dir/$event" ]; then
SESSION_LEVEL="$event"
return 0
fi
done
}
# Only for a registry entry written before pid= existed, i.e. a session that
# outlived an upgrade: how long after its own started= it may still have no
# container before the prune treats it as gone. Sized against a cold first
@@ -96,6 +131,7 @@ PRE_PID_STARTUP_GRACE=900
read_entry() {
local file="$1" line
entry_workspace=""; entry_pane=""; entry_label=""; entry_started=""; entry_pid=""
entry_name=""
[ -r "$file" ] || return 1
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
@@ -104,6 +140,7 @@ read_entry() {
label=*) entry_label="${line#*=}" ;;
started=*) entry_started="${line#*=}" ;;
pid=*) entry_pid="${line#*=}" ;;
name=*) entry_name="${line#*=}" ;;
esac
done < "$file" || return 1
# pid reaches `kill`, which takes a job spec as well as a number: "%1" or
@@ -122,36 +159,97 @@ read_entry() {
esac
}
# The container that reports this run ID. bin/dejima passes DEJIMA_RUN_ID in
# as env precisely so this correlation is possible without the container
# having any write access to the registry.
# Every running sandbox's container, keyed by the run id it reports.
# bin/dejima passes DEJIMA_RUN_ID in as env precisely so this correlation is
# possible without the container having any write access to the registry.
#
# Exit status distinguishes "no such container" (1) from "couldn't ask" (2):
# the caller prunes on the former, and pruning on the latter would permanently
# unregister every running sandbox the moment dockerd happens to be restarting.
container_for() {
local run_id="$1" names name env unasked=""
# Built once per listing rather than once per session: this used to run
# `docker ps` and then inspect containers one by one until it found the match,
# for every entry in the registry in turn, so a dozen sessions cost a dozen
# `docker ps` calls and up to a hundred and forty-four inspects. The listing
# is what both front doors open on, and that is time an operator waits.
#
# CONTAINER_MAP_UNASKED is what keeps "no such container" (1) and "couldn't
# ask" (2) apart downstream: the caller prunes on the former, and pruning on
# the latter would permanently unregister every running sandbox the moment
# dockerd happened to be restarting. One unanswerable inspect anywhere makes
# every miss a 2, which is the same reach the per-session loop had.
declare -A CONTAINER_BY_RUN=()
CONTAINER_MAP_UNASKED=""
load_container_map() {
local names line id name
CONTAINER_BY_RUN=()
CONTAINER_MAP_UNASKED=""
names="$(docker ps --filter "label=com.docker.compose.project=dejima" \
--filter "status=running" --format '{{.Names}}' 2>/dev/null)" || return 2
for name in $names; do
# Captured rather than piped straight into grep: a pipeline reports
# only grep's status, which would turn "inspect couldn't answer for
# this container" into "this isn't the one" - and one such container
# anywhere in the list would prune an unrelated live session.
env="$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$name" 2>/dev/null)" \
|| { unasked=1; continue; }
if printf '%s\n' "$env" | grep -qxF "DEJIMA_RUN_ID=$run_id"; then
printf '%s' "$name"
return 0
fi
done
[ -z "$unasked" ] || return 2
--filter "status=running" --format '{{.Names}}' 2>/dev/null)" \
|| { CONTAINER_MAP_UNASKED=1; return 0; }
[ -n "$names" ] || return 0
# One inspect for every container rather than one each. Two round trips to
# the daemon where there were one plus one per container, which on a host
# running a dozen sessions was most of the wait before the picker drew.
#
# Both values as JSON, which is what makes one line per container a
# property rather than a hope: a newline inside an environment value is
# escaped rather than printed, so a line cannot be split and the name
# cannot drift away from the environment beside it. A missing container
# takes its line with it instead of shifting the rest.
#
# Read with shell string operations rather than a JSON parser: the only
# question here is which element STARTS with DEJIMA_RUN_ID=, and an
# unescaped quote is exactly where an element starts. A value that merely
# contained that text would carry a \" there and not match.
local -a name_list=()
mapfile -t name_list <<< "$names"
local out
# A container that went away between the two calls makes inspect exit
# nonzero while still answering for the others. Unverified rather than
# gone, which is the same answer one failed inspect gave when there was one
# per container: pruning on that unregisters a live session for good.
out="$(docker inspect --format '{{json .Name}} {{json .Config.Env}}' \
"${name_list[@]}" 2>/dev/null)" || CONTAINER_MAP_UNASKED=1
while IFS= read -r line; do
case "$line" in *'"DEJIMA_RUN_ID='*) ;; *) continue ;; esac
id="${line#*\"DEJIMA_RUN_ID=}"
id="${id%%\"*}"
valid_run_id "$id" || continue
# {{json .Name}} is a quoted "/name"; docker ps gave the same names
# without the slash, and the registry is keyed by neither.
name="${line%% *}"
name="${name#\"}"; name="${name%\"}"; name="${name#/}"
CONTAINER_BY_RUN["$id"]="$name"
done <<< "$out"
}
# The container reporting this run id, out of the map above. The answer comes
# back in a variable rather than on stdout, like the two readers below: every
# one of these is called once per session, and `$(...)` is a forked subshell
# per call - which on a host running a dozen sessions was more of the wait than
# the work.
CONTAINER_NAME=""
container_for() {
CONTAINER_NAME="${CONTAINER_BY_RUN[$1]:-}"
[ -z "$CONTAINER_NAME" ] || return 0
[ -z "$CONTAINER_MAP_UNASKED" ] || return 2
return 1
}
# Every pane on the server, asked once. This was a `tmux list-panes` and a
# grep per session, so a dozen sessions meant a dozen round trips to the tmux
# server and two dozen processes, for one answer that does not change while a
# listing is being built.
# Every caller of pane_alive loads this first: list_sessions and count_waiting
# once for the listing they are building, and attach() again at the moment it
# matters. A path that forgets reads an empty list and calls every session
# stale, which is loud rather than subtle - it is what the attach tests catch.
PANE_LIST=""
load_pane_list() {
PANE_LIST=$'\n'"$(tmux list-panes -a -F '#{pane_id}' 2>/dev/null || true)"$'\n'
}
pane_alive() {
[ -n "$1" ] || return 1
tmux list-panes -a -F '#{pane_id}' 2>/dev/null | grep -qxF "$1"
case "$PANE_LIST" in *$'\n'"$1"$'\n'*) return 0 ;; esac
return 1
}
# Does session $1 actually hold pane $2? The only question that separates the
@@ -197,16 +295,31 @@ launcher_alive() {
# no matching container - and old enough that it should have had one - is
# pruned outright; one whose pane is gone is shown as stale rather than
# offered for attachment.
#
# Oldest first, by start time, which is the one order that holds still while an
# operator is reading it: the numbers in the picker are how a session is
# selected, so a list that re-sorted itself by state - the tempting order for a
# live view - would renumber the rows between reading one and typing it. A new
# session appears at the end and changes nothing above it.
list_sessions() {
local file run_id container state now rc warned=""
# The sort key is prepended by the loop and dropped here, so the columns
# the picker and --list read are unchanged.
list_sessions_unsorted | sort -n | cut -f2-
}
list_sessions_unsorted() {
local file run_id container state project now rc warned=""
[ -d "$SESSIONS_DIR" ] || return 0
now="$(date +%s)"
load_container_map
load_pane_list
for file in "$SESSIONS_DIR"/*; do
[ -f "$file" ] || continue
run_id="$(basename "$file")"
valid_run_id "$run_id" || continue
rc=0
container="$(container_for "$run_id")" || rc=$?
container_for "$run_id" || rc=$?
container="$CONTAINER_NAME"
if [ "$rc" -eq 1 ] && launcher_alive "$file" "$now"; then
# Registered, but bin/dejima is still building - "not started yet"
# is a third answer, and pruning on it would unregister a session
@@ -249,12 +362,102 @@ list_sessions() {
else
state="stale"
fi
printf '%s\t%s\t%s\t%dm\t%s\t%s\n' \
"$run_id" "${entry_label:-?}" "$(basename "$entry_workspace")" \
# One column for two questions, because they do not overlap: a session
# that cannot be attached to has nothing useful to say about whether it
# wants attention, and for every session that CAN be attached to,
# "attachable" is the answer to a question nobody asked. The words are
# distinct, so the column stays unambiguous while the row stays narrow
# enough for a phone.
# ${x##*/} rather than basename, which is a process per row.
project="${entry_workspace##*/}"
[ -n "$project" ] || project="$entry_workspace"
if [ "$state" = attachable ]; then
session_level "$run_id"
# `done` and `waiting` are the harness events the hook reports -
# a turn ended, and something was asked for. One state to an
# operator, who does the same thing about either.
case "$SESSION_LEVEL" in
done|waiting) state="waiting" ;;
running) state="working" ;;
esac
fi
# The name is second, before the columns that were already here, so a
# row reads id-name-project rather than ending on the one field an
# operator scans for. It is empty for a session launched without
# --name, which is what the picker's fallback to the project is for.
printf '%d\t%s\t%s\t%s\t%s\t%dm\t%s\t%s\n' \
"$(( 10#$entry_started ))" \
"$run_id" "$entry_name" "${entry_label:-?}" "$project" \
"$(( (now - 10#$entry_started) / 60 ))" "$state" "${container:0:12}"
done
}
# What every live session is doing, counted in one pass, for a status line to
# interpolate. Registry, panes and levels only: no docker, and nothing pruned.
#
# A status line re-runs this every few seconds, where --list is read once by a
# person, so the one question that costs a round trip to the daemon per
# container is the one question this does not ask. What that gives up is
# noticing a session whose container died without taking bin/dejima with it -
# counted here, shown as gone by every listing - and telling a session whose
# image is still building from one that is simply quiet; both read as
# `attachable`. The pane list is one tmux call for all of them, because the
# alternative is one per session.
#
# The words are the picker's, so that a number on a status bar and a row on a
# screen say the same thing about the same session rather than two vocabularies
# meeting in an operator's head.
declare -A SESSION_COUNTS=()
count_states() {
local file run_id now state
SESSION_COUNTS=([waiting]=0 [working]=0 [attachable]=0 [stale]=0 [no-tmux]=0 [total]=0)
[ -d "$SESSIONS_DIR" ] || return 0
now="$(date +%s)"
load_pane_list
for file in "$SESSIONS_DIR"/*; do
[ -f "$file" ] || continue
run_id="$(basename "$file")"
valid_run_id "$run_id" || continue
# A session whose launcher is gone is one the exit trap did not get to
# remove. Not counted at all rather than counted as broken: the listing
# is where an operator goes to find out about those, and a status bar
# that never reaches zero stops being read.
launcher_alive "$file" "$now" || continue
read_entry "$file" || continue
if [ -z "$entry_pane" ]; then
state=no-tmux
elif ! pane_alive "$entry_pane"; then
state=stale
else
session_level "$run_id"
case "$SESSION_LEVEL" in
done|waiting) state=waiting ;;
running) state=working ;;
*) state=attachable ;;
esac
fi
SESSION_COUNTS[$state]=$(( SESSION_COUNTS[$state] + 1 ))
SESSION_COUNTS[total]=$(( SESSION_COUNTS[total] + 1 ))
done
}
count_waiting() {
count_states
printf '%s\n' "${SESSION_COUNTS[waiting]}"
}
# One line of key=value, in the order a reader cares about them. A line rather
# than a number because two numbers cost two invocations otherwise, and this
# one is run on a timer.
counts_line() {
local key out=""
count_states
for key in waiting working attachable stale no-tmux total; do
out="$out$key=${SESSION_COUNTS[$key]} "
done
printf '%s\n' "${out% }"
}
# Attaches through a GROUPED session rather than the operator's own. Grouped
# sessions share windows but track their own current window, so a phone can
# sit on a different sandbox than the desktop, and destroy-unattached disposes
@@ -278,6 +481,9 @@ attach() {
echo "Session $run_id was not started from tmux - nothing to attach to." >&2
exit 1
fi
# Freshly, not from whatever a listing left behind: the pane may have gone
# away in the seconds between an operator reading the row and picking it.
load_pane_list
if ! pane_alive "$entry_pane"; then
echo "Session $run_id is stale - its tmux pane is gone." >&2
exit 1
@@ -306,6 +512,20 @@ attach() {
echo "Session $run_id is stale - its tmux pane went away." >&2
exit 1
fi
# Already inside tmux: move this client rather than attaching. Everything
# below is written for a client that has no tmux of its own - a phone, an
# ssh forced command - and tmux refuses to nest a session inside one of its
# own panes, so on the operator's own machine the picker could list
# sessions and then fail to reach any of them. Switching is also the
# cheaper thing by a long way: no throwaway session to create, verify and
# reap, and the window the operator lands on is the real one.
#
# switch-client rather than select-window, so a sandbox launched from some
# other tmux session is reachable too and not just a window of this one.
if [ -n "${TMUX:-}" ]; then
tmux switch-client -t "=$base:$win"
return 0
fi
if tmux has-session -t "=$group" 2>/dev/null; then
# Verified exactly like the session created below, and for the same
# reason: nothing here created this one, so carrying the name is not
@@ -366,51 +586,150 @@ attach() {
trap - EXIT
}
# An age a glance can read: minutes for the first hour, then hours, then days.
# `--list` keeps the minutes it has always printed, because a number is what a
# script wants; three characters is what lets the row fit the phone, and "2h"
# is what tells an operator a session has been working since before lunch
# where "137m" is a sum to do first.
AGE_SHORT=""
age_short() { # <minutes, as --list prints them>
local m="${1%m}"
if [ "$m" -lt 60 ]; then AGE_SHORT="${m}m"
elif [ "$m" -lt 2880 ]; then AGE_SHORT="$(( m / 60 ))h"
else AGE_SHORT="$(( m / 1440 ))d"
fi
}
# Draws the list and reads a choice. With a timeout it returns 1 when nothing
# was typed, which is what --watch loops on.
picker() {
local -a ids=() labels=() projects=() ages=() states=() containers=()
local i choice run_id label project age state container
local w_label=0 w_project=0 w_age=0 w_state=0
# Split on tabs only, so a space in a project name stays part of one field.
# The columns are measured on the way in because they are padded to the
# widest value rather than to a guess: a project name is a directory name,
# and a fixed width either truncates a long one or indents every short one
# off the right edge of a phone.
while IFS=$'\t' read -r run_id label project age state container; do
ids+=("$run_id"); labels+=("$label"); projects+=("$project")
ages+=("$age"); states+=("$state"); containers+=("$container")
[ "${#label}" -le "$w_label" ] || w_label="${#label}"
[ "${#project}" -le "$w_project" ] || w_project="${#project}"
[ "${#age}" -le "$w_age" ] || w_age="${#age}"
local timeout="${1:-}"
local -a ids=() names=() ages=() states=()
local i choice run_id name label project age state container
local w_name=0 w_state=0
# Three columns, because the front door this is read through is a phone.
# The tmux label and the container id are in `--list` and not here: the
# label is a coordinate an operator no longer has to act on, since picking
# a session goes to it, and the container id shares a prefix with every
# other one, so a column of them says nothing. Carrying all five made a row
# about eighty columns wide, which wrapped onto a second line and put the
# state and the age under the NEXT session's number.
#
# The name is first, against the index, because those two are what an
# operator matches up: the number is only useful next to the thing it
# selects. It is measured on the way in rather than padded to a guess, and
# capped, since the padding decides where the state sits.
# Split on a unit separator, not on the tab the rows are built with. A tab
# is IFS whitespace, so a RUN of them collapses into one delimiter: a row
# whose name is empty - every entry written before that field existed -
# loses the field entirely and shifts the five after it along. What that
# looked like was the tmux label sitting where the name goes, the state
# sitting in the age column, and the row coloured from the container id,
# so a stale session was neither red nor labelled stale where it was read.
while IFS=$'\037' read -r run_id name label project age state container; do
# The project stands in for an entry written before the name existed.
[ -n "$name" ] || name="$project"
# The two answers the container column carried that were not
# decoration, where they are read rather than in a column of their
# own: a session whose image is still building is not an ordinary
# attachable one, and "?" is already said once on stderr for the whole
# listing when docker could not be asked at all.
[ "$container" != starting ] || state=starting
ids+=("$run_id"); names+=("$name"); states+=("$state")
age_short "$age"
ages+=("$AGE_SHORT")
[ "${#name}" -le "$w_name" ] || w_name="${#name}"
[ "${#state}" -le "$w_state" ] || w_state="${#state}"
done < <(list_sessions)
done < <(list_sessions | tr '\t' '\037')
banner
if [ "${#ids[@]}" -eq 0 ]; then
printf ' %sNo live sessions.%s\n\n' "$C_MUTED" "$C_OFF"
# A live view waits for one to appear, where the one-shot picker has
# its answer and is done. Launching a session is exactly when an
# operator is watching this, so exiting here would close the window
# they opened to watch it in.
[ -z "$timeout" ] || { sleep "$timeout"; return 1; }
exit 0
fi
# The container column stays, and last, because it is not decoration: it
# reads "starting" for a session bin/dejima is still building and "?" when
# docker could not be asked at all, and dropping it would make either one
# look like an ordinary attachable session.
# The name column takes whatever the terminal has left after the fixed
# parts, rather than a width picked here. A fixed one is wrong in both
# directions at once: a desktop has room for every name and would cut them
# anyway, and a narrow phone would still overflow and wrap, which is the
# failure this layout exists to avoid. 80 when there is no terminal to ask,
# which is the width of a pipe by convention.
#
# 15 is the row minus the name: two leading spaces, two for the index, two
# spaces, the dot, a space, two spaces after the name, two before the age
# and three for the age itself.
#
# Asked of the controlling terminal, not of stdin. `tput cols` reads the
# size from fd 0, and this picker's stdin is whatever the front door handed
# it - a pipe wherever the choice is fed in rather than typed - where tput
# answers with terminfo's 80 and names are cut on a phone that has fewer.
local cols budget
cols="$(stty size </dev/tty 2>/dev/null | cut -d' ' -f2 || true)"
case "$cols" in ''|*[!0-9]*) cols=80 ;; esac
# A pty nobody set a size on reports zero columns, which is not a width but
# an unanswered question: taken literally it clamps every name to the floor
# below, on a terminal that may be perfectly wide.
[ "$cols" -ge 20 ] || cols=80
budget=$(( cols - 15 - w_state ))
# A terminal too narrow for a name at all still gets something to read.
[ "$budget" -ge 8 ] || budget=8
[ "$w_name" -le "$budget" ] || w_name="$budget"
for i in "${!ids[@]}"; do
local sc; sc="$(state_color "${states[$i]}")"
printf ' %s%2d%s %s%s%s %s%-*s%s %s%-*s%s %s%*s%s %s%-*s%s %s%s%s\n' \
state_color "${states[$i]}"
# Cut names are marked. A name quietly losing its last characters is
# worse than a short column: two projects can share twenty-four
# characters and differ in the twenty-fifth, and nothing on the row
# would say so.
#
# An ASCII marker even where the rest of the glyphs are not, because
# this one sits INSIDE a padded field: bash measures a multi-byte
# character as its bytes wherever the locale is not UTF-8, and printf
# then pads the field to that many bytes, so the row would come out two
# columns short and take the state column with it. This script runs
# under whatever locale a forced ssh command or the ttyd unit has.
if [ "${#names[$i]}" -gt "$w_name" ]; then
names[$i]="${names[$i]:0:$(( w_name - 1 ))}~"
fi
printf ' %s%2d%s %s%s%s %s%-*s%s %s%-*s%s %s%3s%s\n' \
"$C_WARN" "$((i + 1))" "$C_OFF" \
"$sc" "$GLYPH_DOT" "$C_OFF" \
"$C_BOLD$C_TEXT" "$w_project" "${projects[$i]}" "$C_OFF" \
"$C_LINK" "$w_label" "${labels[$i]}" "$C_OFF" \
"$C_MUTED" "$w_age" "${ages[$i]}" "$C_OFF" \
"$sc" "$w_state" "${states[$i]}" "$C_OFF" \
"$C_SUBTLE" "${containers[$i]}" "$C_OFF"
"$STATE_COLOR" "$GLYPH_DOT" "$C_OFF" \
"$C_BOLD$C_TEXT" "$w_name" "${names[$i]}" "$C_OFF" \
"$STATE_COLOR" "$w_state" "${states[$i]}" "$C_OFF" \
"$C_MUTED" "${ages[$i]}" "$C_OFF"
done
tui_pick "${#ids[@]}" 'attach to'
tui_pick "${#ids[@]}" 'attach to' "$timeout" || return 1
choice="$TUI_CHOICE"
attach "${ids[$((choice - 1))]}"
}
# How often --watch redraws. Five seconds, because a redraw is a full listing -
# one `docker ps`, one inspect per container - and the thing being watched is a
# session either finishing a turn or starting one, which is not a sub-second
# event. The screen clear in the banner is what keeps a redraw from scrolling.
WATCH_INTERVAL=5
# The at-a-glance view: the same picker, redrawn until something is typed.
# Deliberately not a second tool. A separate monitor would have to grow its own
# list, its own columns and its own idea of what a session is, and the thing an
# operator does next after seeing which session wants them is to go to it -
# which this can already do, through the same numbers.
watch_picker() {
while ! picker "$WATCH_INTERVAL"; do :; done
}
case "${1:-}" in
'') picker ;;
--watch) watch_picker ;;
--list) list_sessions ;;
--count-waiting) count_waiting ;;
--counts) counts_line ;;
# Where the registry and the reported states live, for anything on this
# host that wants to read them without re-deriving which of
# XDG_RUNTIME_DIR and /tmp this user got.
--state-dir) printf '%s\n' "$STATE_DIR" ;;
*)
# This argument arrives from ttyd's --url-arg, i.e. from whoever can
# reach the web front door. Validate its shape before it is used as a
+17 -2
View File
@@ -84,9 +84,14 @@ tui_banner() {
#
# Numbered rather than arrow-driven: phone soft keyboards handle digits far
# better than arrow keys, and dejima-attach is the phone's entry point.
# A third argument makes the prompt time out after that many seconds and
# return 1 with no choice, for a caller that redraws and asks again. Kept here
# rather than in the caller's own read so that there is one prompt, one range
# check and one set of exits: a live list whose prompt behaved differently from
# the static one is two front doors wearing the same colours.
TUI_CHOICE=""
tui_pick() {
local count="$1" verb="$2" choice
local count="$1" verb="$2" timeout="${3:-}" choice rc=0
TUI_CHOICE=""
printf '\n %s%s%s %s[1-%d, q to quit]%s: ' \
"$C_MUTED" "$verb" "$C_OFF" "$C_TEXT" "$count" "$C_OFF"
@@ -94,7 +99,17 @@ tui_pick() {
# controlling terminal (`ssh -T`, or an SSH app that requests no pty) this
# returns nonzero immediately, and exiting 1 with nothing printed reads as
# a broken front door.
read -r choice || exit 0
#
# A timed read separates the two: bash reports a timeout as a status above
# 128 and an unreadable stdin as 1, so a front door with no terminal still
# exits rather than spinning on a prompt nobody can answer.
if [ -n "$timeout" ]; then
read -r -t "$timeout" choice || rc=$?
[ "$rc" -le 128 ] || return 1
[ "$rc" -eq 0 ] || exit 0
else
read -r choice || exit 0
fi
case "$choice" in
q|Q|'') exit 0 ;;
*[!0-9]*) echo "Not a number." >&2; exit 1 ;;
+15 -9
View File
@@ -13,14 +13,14 @@
# Registers the `done` notification handler into dejima's settings layer, in
# the shape Claude Code reads them.
#
# Stop only, though the hook and the host-side watcher both understand
# `waiting` as well. Claude Code raises Notification when it needs permission
# to run something and when the prompt has sat idle, and a dejima session runs
# with permission prompts off - so the first never happens, and what is left
# is a timer that expires shortly after the Stop it follows. Registering both
# put two pushes on the operator's phone for one event, a minute apart, the
# second saying nothing the first had not. `waiting` stays in the enum for a
# harness that can say it without having stopped first.
# No Notification handler, though the hook and the host-side watcher both
# understand `waiting` as well. Claude Code raises Notification when it needs
# permission to run something and when the prompt has sat idle, and a dejima
# session runs with permission prompts off - so the first never happens, and
# what is left is a timer that expires shortly after the Stop it follows.
# Registering both put two pushes on the operator's phone for one event, a
# minute apart, the second saying nothing the first had not. `waiting` stays in
# the enum for a harness that can say it without having stopped first.
#
# Claude Code merges hook entries across settings layers rather than letting
# the higher one replace the lower, so these are added to whatever the
@@ -40,7 +40,13 @@ DEJIMA_HOOK_DIR=/usr/local/lib/dejima/hooks
harness_register_notify() {
local settings="$1" tmp
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "'"$DEJIMA_HOOK_DIR"'/dejima-notify.sh done"}]}])' \
# UserPromptSubmit is the complement of Stop, and it is registered for the
# level alone: the hook writes no notification edge for `running`, so this
# adds nothing to the phone. What it adds is the difference between "this
# session has stopped" and "nothing has been heard from this session",
# which a picker cannot tell apart from the Stop event by itself.
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "'"$DEJIMA_HOOK_DIR"'/dejima-notify.sh done"}]}])
| .hooks.UserPromptSubmit = ((.hooks.UserPromptSubmit // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "'"$DEJIMA_HOOK_DIR"'/dejima-notify.sh running"}]}])' \
"$settings" > "$tmp"; then
mv "$tmp" "$settings"
else
+1 -1
View File
@@ -101,7 +101,7 @@ why each is not a test:
| `forges/`, the forge guard | Open a pull request through each forge and read the body that arrived |
| any guard | Try the refused thing by the route an agent would take, not by the route the test takes |
| `bin/dejima-tty-suspend`, the launch | Ctrl-Z a real session and `fg` it: the tests use a stand-in for the container run |
| `web/` | Load it on a phone; nothing here can see a soft keyboard |
| `web/` | `cd web && node test/browser/sess-button.mjs`, then load it on a phone; nothing here can see a soft keyboard |
| `harnesses/` | Launch the harness against a real model |
## 5. The release pull request
+155 -7
View File
@@ -1013,30 +1013,178 @@ debounce instead.
than arrow keys, because a phone soft keyboard handles those far better.
`--list` prints one tab-separated row per live session for scripting, and a
run-id argument attaches directly, which is what the deep link and `ttyd`'s
`--url-arg` produce. Rows carry the run id, the tmux label, the workspace
name, the session's age, its state, and the container id.
`--url-arg` produce. Oldest first in both: the numbers are how a session is
picked, so the order holds still rather than following the state.
**The picker shows three columns - name, state, age** - and the name comes
first, against the index, because those two are what you match up. A `--list`
row carries everything: the run id, the session name, the tmux label, the
workspace name, the age in minutes, the state, and the container id. The label
and the container id are deliberately not in the picker. Carrying all five made
a row about eighty characters wide, which on a phone wrapped onto a second line
and put the state and the age underneath the *next* session's number; and
neither is something to act on - picking a session goes to its window for you,
and every container id shares a prefix with every other.
The name column is whatever the terminal has room for after the rest of the
row, so a desktop shows every name whole and a phone shows as much as it can.
A name that did not fit is cut with a `~`, because two projects can differ only
past the cut and a row that said nothing about it would be worse than a short
column. `--list` always carries the whole name.
**Every session has a name, and `dejima --name <text>` is how you choose it.**
Without the flag it is the project directory's name, which is what both front
doors showed before and is the right answer for the usual one-session case.
**A name already in use gets the first free `-N`**, so a second session
against the same project is `myproject-2` rather than a second row reading
`myproject` - and rather than a launch that refuses to start, which would be a
worse answer than an ambiguous row. The first *free* suffix: a number a
finished session gives back is used again, instead of the count climbing for
the life of the host. Collisions are read from the live registry and from the
tmux window list, because each is blind to a case the other sees. A name you
passed explicitly is taken verbatim, collision or not; you typed it for this
launch.
Nothing reserves a name, so two launches in the same instant can still land on
one. That costs a pair of rows that read alike, which is where this started
rather than a failure, and the alternative is a lock held across a cold image
build.
A derived name is cut down where an explicit one is refused: control
characters are stripped and the whole thing is truncated to 32 characters,
leaving room for the suffix. A directory was not named for this purpose, so a
launch that stopped because a path was long would be refusing over something
it can fix itself.
The name is also the **tmux window name**, which is the part worth having even
with remote access off: `choose-window`, the status line and the window list
all show that name, and for a dozen sandboxes they otherwise show a dozen
copies of whatever `automatic-rename` makes of the container command. A window
you renamed yourself is left alone unless the launch passed `--name`.
The picker is coloured (gruvbox) when stdout is a terminal that says it can
show it; `NO_COLOR=1` or a `dumb` `$TERM` turns that off, along with the
box-drawing glyphs. `--list` is never coloured, terminal or not - the picker
parses those rows itself and `dejima-attach --help` calls them scriptable.
Three states, from a registry `dejima` writes on launch and removes on
exit:
The state column answers two questions with one word, because they do not
overlap: a session that cannot be attached to has nothing useful to say about
whether it wants attention, and for one that can, "attachable" is the answer
to a question nobody asked.
Reachability, from a registry `dejima` writes on launch and removes on exit:
- `attachable` - a live tmux pane to join.
- `stale` - registered, but its pane is gone. Listed rather than hidden,
since the registry is a hint and not truth: the exit trap doesn't run
under `SIGKILL` or a host crash.
- `no-tmux` - the sandbox was started outside tmux, so there is no pane to
attach to. Still listed, deliberately, rather than silently omitted.
And for a session with a pane to join, what it last said about itself:
- `waiting` - it finished a turn, or asked for something, and has not been
given anything since. Both of the harness events the hook reports,
[`done` and `waiting`](#notifications), land here: they are one thing to an
operator, who does the same about either.
- `working` - it was given a prompt and has not stopped.
- `starting` - its image is still being built, so there is no session in there
to attach to yet. In the picker only: `--list` says this in its container
column, which the picker does not carry.
- `attachable` - it has said nothing yet. A harness that reports no state at
all stays here for its whole life, and so does a session between launching
and its first prompt.
That reporting is the notification hook writing one file into a directory of
its own, overwritten rather than consumed: the markers the
[notification watcher](#notifications) consumes are gone the moment it pushes
them, and with no watcher configured nothing reads them at all. So the states
above work whether or not you use notifications, which is the point - they are
the answer to "which sessions need me" for an operator who is at the machine.
It is the session reporting on itself, so it is best-effort by construction. A
harness killed mid-turn leaves `working` behind, and the age column is the
thing to read next to it.
Entries whose container has gone away are pruned as they're listed. When
Docker can't be asked at all, nothing is pruned and the container column
reads `?` - an unanswerable question is never treated as a "no".
Attaching creates a **grouped** tmux session (`dejima-<run-id>`) rather than
joining yours. Grouped sessions share windows but track their own current
### Watching all of them, and getting to one
`dejima-attach --watch` is the same picker redrawn every five seconds. One
window showing which sessions are working and which are waiting, with the same
numbers to go to one. A redraw is a full listing, which is two questions to
Docker and one to tmux however many sessions are running.
`dejima-attach --count-waiting` reduces it to a number, for a tmux status line:
```tmux
# ~/.tmux.conf - how many sessions are waiting for you, in the status bar
set -g status-interval 10
set -ag status-right ' waiting: #(dejima-attach --count-waiting)'
```
That path reads the registry, the pane list and the reported states and asks
Docker nothing, because the point of the number is not having to open
anything. What it gives up is noticing a session whose container died without
taking its launcher with it - every listing shows that one as gone - and
telling a session whose image is still building from one that is simply quiet;
both read as `attachable`.
`--counts` answers for every state in the same single pass, because two
numbers on a status bar would otherwise cost two invocations of something that
runs on a timer:
```console
$ dejima-attach --counts
waiting=3 working=5 attachable=8 stale=0 no-tmux=0 total=16
```
The words are the picker's, so a number on a status bar and a row on a screen
say the same thing about the same session. Formatting is yours; `cut` is
enough for the usual case, and nothing here decides how your status bar looks:
```tmux
set -ag status-right ' #(dejima-attach --counts | cut -d" " -f1,2)'
```
`--state-dir` prints where the registry and the reported states live, for
anything on this host that would rather read the files than run this. It is
under `$XDG_RUNTIME_DIR` when there is one and under `/tmp` when there is not,
so it is not a path to hard-code:
```console
$ ls "$(dejima-attach --state-dir)"/notify/*/level
```
The filename is the state, and it is the harness event rather than the word
the picker shows: `done` or `waiting` is a session waiting for you, `running`
is one working.
**The SESS button does not reload the page.** ttyd takes the run id as a
WebSocket argument rather than as part of the page, so pressing it closes the
socket and opens another without that argument, which is `dejima-attach` with
no argument, which is the picker. The half a megabyte of inlined terminal
stays loaded and stays running; on a phone, fetching and re-running it was
most of the wait. Closing the socket is also what ends the session you are
leaving, exactly as navigating away used to: ttyd kills the command when its
connection goes.
The terminal is reset on the way, and the mode tracker with it. What the
socket carries away is the process, not the state it left behind in the
terminal - an alternate screen, mouse reporting, a cursor key mode - and the
picker would otherwise open into whatever the session had switched on.
**Inside tmux, the picker switches your client to the session's own window**
rather than attaching. tmux refuses to nest a session inside one of its own
panes, so on your own machine the picker would otherwise list every session
and reach none of them - and switching is what makes it the window switcher
that a dozen parallel sessions actually need. `prefix` + a window number stops
at nine; this does not.
Attaching, on a client with no tmux of its own, creates a **grouped** tmux
session (`dejima-<run-id>`) rather than joining yours. Grouped sessions share windows but track their own current
window, so the phone can sit on one sandbox while the desktop sits on
another, and the throwaway carries `destroy-unattached`, so it disposes of
itself when the phone detaches or the connection drops.
+22 -2
View File
@@ -20,7 +20,7 @@ set -euo pipefail
# Sanitizing an arbitrary name into a marker would make the file name a
# channel the sandbox controls, which is the one thing this hook must not be.
case "${1:-}" in
waiting|done) ;;
waiting|done|running) ;;
*) exit 0 ;;
esac
@@ -32,4 +32,24 @@ esac
[ -n "${DEJIMA_NOTIFY_DIR:-}" ] || exit 0
[ -d "$DEJIMA_NOTIFY_DIR" ] || exit 0
: > "$DEJIMA_NOTIFY_DIR/$1"
# The notification edge, for dejima-notify-watch to consume and push. Two
# names, because that is the set the watcher iterates: a marker it never looks
# for is a file nothing ever removes.
case "$1" in
waiting|done) : > "$DEJIMA_NOTIFY_DIR/$1" ;;
esac
# The level: which of those the session is in NOW, rather than that it just
# changed. Nothing consumes it, so it answers "which sessions need me" for a
# picker or a status line, and it does that whether or not a watcher is
# running - the edge above is gone the moment one is. Still a name and no
# content, and still only ever about this session: the directory is this run's
# own, so the worst a compromised session can do here is misreport itself.
#
# Siblings first, then the new one: a reader between the two calls sees no
# state rather than two, and "unknown" is a thing the readers already render.
[ -d "$DEJIMA_NOTIFY_DIR/level" ] || exit 0
for _other in waiting 'done' running; do
[ "$_other" = "$1" ] || rm -f "$DEJIMA_NOTIFY_DIR/level/$_other"
done
: > "$DEJIMA_NOTIFY_DIR/level/$1"
+31
View File
@@ -586,6 +586,37 @@ write_defaults() { printf '%s\n' "$@" > "$CONFIG_DIR/defaults.conf"; }
return 0
}
@test "the session name has no config key either, and refuses what would break a row" {
# Same reasoning as the mode above, from the other direction: a name every
# launch of a project shares is the ambiguity --name exists to remove, so
# a file is the one place it cannot usefully live.
local key
for key in name session_name label; do
write_config '[core]' "$key = whatever"
run lib config_validate
[ "$status" -ne 0 ] \
|| { echo "[core] $key was accepted"; return 1; }
[[ "$output" == *"unknown key"* ]] || { echo "$output"; return 1; }
done
# The value lands in a flat key=value registry entry that dejima-attach
# parses last-match-wins, and in a tab-separated listing. A newline or a
# tab is refused rather than sanitized, like the workspace path beside it.
local bad
for bad in $'two\nlines' $'two\tcolumns' $'bell\a'; do
run "$REPO_ROOT/bin/dejima" --name "$bad" .
[ "$status" -ne 0 ] || { echo "accepted a control character"; return 1; }
[[ "$output" == *"control character"* ]] || { echo "$output"; return 1; }
done
run "$REPO_ROOT/bin/dejima" --name "$(printf 'x%.0s' {1..33})" .
[ "$status" -ne 0 ] || { echo "accepted 33 characters"; return 1; }
[[ "$output" == *"32 is the most"* ]] || { echo "$output"; return 1; }
# And the boundary itself is accepted, or the cap is off by one: this gets
# past the name check and fails on something else further in.
run "$REPO_ROOT/bin/dejima" --name "$(printf 'x%.0s' {1..32})" .
[[ "$output" != *"32 is the most"* ]] || { echo "32 characters was refused"; return 1; }
}
@test "an invalid value is fatal and names the key" {
write_config '[audit]' 'keep = 0'
run lib config_validate
+669 -2
View File
@@ -204,6 +204,18 @@ for arg in "$@"; do
[ -z "${SANDBOX_TEST_SETTINGS_COPY:-}" ] || cp "$SANDBOX_SETTINGS_FILE" "$SANDBOX_TEST_SETTINGS_COPY"
break
done
# The marker directory and what the host created inside it, while it still
# exists: bin/dejima's EXIT trap rm -rf's the whole thing, so from outside the
# run there is nothing left to look at. The host path comes off the mount
# argument, which is the only place this stub can learn it.
if [ -n "${SANDBOX_TEST_LEVEL_PROBE:-}" ]; then
for arg in "$@"; do
case "$arg" in
*:/run/dejima-notify)
[ ! -d "${arg%%:*}/level" ] || echo level >> "$SANDBOX_TEST_LEVEL_PROBE" ;;
esac
done
fi
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
@@ -232,6 +244,7 @@ launch() {
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
SANDBOX_TEST_SETTINGS_COPY="$LAUNCH_DIR/settings-copy.json" \
SANDBOX_TEST_LEVEL_PROBE="$LAUNCH_DIR/level-probe" \
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
"${DEJIMA_BIN:-$REPO_ROOT/bin/dejima}" "$@"
}
@@ -610,7 +623,9 @@ stub_docker_run_id() {
#!/bin/bash
case "\$1" in
ps) echo dejima-test-container ;;
inspect) echo "DEJIMA_RUN_ID=$1" ;;
# One line per container, both values JSON, which is the shape
# dejima-attach asks for so that one call answers for all of them.
inspect) echo '"/dejima-test-container" ["PATH=/usr/bin","DEJIMA_RUN_ID=$1"]' ;;
esac
exit 0
EOF
@@ -633,6 +648,9 @@ label=$3
started=${4:-$(date +%s)}
EOF
[ -z "${5:-}" ] || printf 'pid=%s\n' "$5" >> "$RUNTIME_DIR/dejima/sessions/$1"
# bin/dejima writes name= first; written last here on purpose, so the
# reader is exercised against a field order it does not control.
[ -z "${6:-}" ] || printf 'name=%s\n' "$6" >> "$RUNTIME_DIR/dejima/sessions/$1"
}
# A pid that is certain to be dead: spawn a process and reap it, so the number
@@ -718,6 +736,552 @@ dead_pid() {
[[ "$output" == *"no-tmux"* ]]
}
# A `docker` that records the launching window's name instead of its argv. The
# rename happens before the first docker call and is undone by the EXIT trap,
# so from outside the run there is no moment at which it is observable - the
# stub is standing in for that moment.
stub_window_probe() {
cat > "$LAUNCH_DIR/docker" <<EOF
#!/bin/bash
tmux display-message -p -t '$TARGET_PANE' '#{window_name}' >> "$LAUNCH_DIR/winname"
# And what the registry recorded for the same launch, which the exit trap
# removes too. The two have to be one value: the window is how an operator
# finds the session and the entry is how both front doors name it.
sed -n 's/^name=//p' "$RUNTIME_DIR"/dejima/sessions/* 2>/dev/null >> "$LAUNCH_DIR/regname"
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
: > "$LAUNCH_DIR/winname"
: > "$LAUNCH_DIR/regname"
}
@test "an unnamed launch takes the project's name, and a colliding one takes a suffix" {
# Most launches pass no --name, so the default is what this feature mostly
# is. A collision must not stop a launch: several sessions against one
# project is the case the name exists for, not an error.
stub_launch
write_remote_config
start_tmux
stub_window_probe
local proj; proj="$(basename "$PROJ")"
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
grep -qxF "$proj" "$LAUNCH_DIR/winname" \
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
# The window and the registry carry one value, not two derivations of it.
grep -qxF "$proj" "$LAUNCH_DIR/regname" \
|| { echo "registry: $(cat "$LAUNCH_DIR/regname")"; return 1; }
# A live session already holding the name. $$ is this bats process, which
# is alive by construction.
write_entry 0000000000000001 '%1' 'work:1' '' $$ "$proj"
: > "$LAUNCH_DIR/winname"
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
grep -qxF "$proj-2" "$LAUNCH_DIR/winname" \
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
write_entry 0000000000000002 '%1' 'work:2' '' $$ "$proj-2"
: > "$LAUNCH_DIR/winname"
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
grep -qxF "$proj-3" "$LAUNCH_DIR/winname" \
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
# The FIRST free suffix: a number a finished session gives back is used
# again rather than the count climbing for the life of the host.
rm "$RUNTIME_DIR/dejima/sessions/0000000000000001"
: > "$LAUNCH_DIR/winname"
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
grep -qxF "$proj" "$LAUNCH_DIR/winname" \
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
# An entry whose launcher is gone is holding no name: the exit trap removes
# the entry, but a session killed outright leaves it behind.
write_entry 0000000000000003 '%1' 'work:3' '' "$(dead_pid)" "$proj"
: > "$LAUNCH_DIR/winname"
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
grep -qxF "$proj" "$LAUNCH_DIR/winname" \
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
# An explicit --name is taken verbatim, collision or not: it was typed for
# this launch, and handing back something else would be worse than two rows
# that agree.
write_entry 0000000000000004 '%1' 'work:4' '' $$ 'mine'
: > "$LAUNCH_DIR/winname"
TMUX_PANE="$TARGET_PANE" launch --name mine "$PROJ"
grep -qxF 'mine' "$LAUNCH_DIR/winname" \
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
rm -rf "$PROJ"
}
@test "a tmux window already holding the name is a collision too" {
# The other source, and the one that works with remote access off: there is
# no registry then, and two windows wearing one name is exactly what the
# name is meant to prevent.
stub_launch
write_remote_config
start_tmux
stub_window_probe
local proj; proj="$(basename "$PROJ")"
tmux rename-window -t work:0 "$proj"
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
grep -qxF "$proj-2" "$LAUNCH_DIR/winname" \
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
# But not this launch's own window, which may already carry the name from
# the session before it or from automatic-rename guessing the directory.
#
# Asserted on the registry rather than on the window: renaming a window by
# hand turns automatic-rename off, which is exactly the case where the
# launch leaves the name alone - so the window would read `$proj` here
# whatever label was chosen, and the assertion would hold for the wrong
# reason.
tmux rename-window -t work:0 'other'
tmux rename-window -t "$TARGET_PANE" "$proj"
: > "$LAUNCH_DIR/regname"
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
grep -qxF "$proj" "$LAUNCH_DIR/regname" \
|| { echo "registry: $(cat "$LAUNCH_DIR/regname")"; return 1; }
rm -rf "$PROJ"
}
@test "a project name too long or too odd for a label is cut down, not refused" {
# --name refuses these shapes because the operator typed them. A directory
# name was not chosen for this, so a launch that stopped over one would be
# refusing over something it can fix itself.
stub_launch
write_remote_config
start_tmux
stub_window_probe
local long; long="$(printf 'x%.0s' {1..50})"
local deep="$PROJ/$long"
mkdir -p "$deep"
TMUX_PANE="$TARGET_PANE" launch "$deep"
[ "$(head -1 "$LAUNCH_DIR/winname")" = "${long:0:32}" ] \
|| { echo "window: $(head -1 "$LAUNCH_DIR/winname")"; return 1; }
# And the suffix still fits, which means the base gives room for it rather
# than the label growing past the cap.
write_entry 0000000000000001 '%1' 'work:1' '' $$ "${long:0:32}"
: > "$LAUNCH_DIR/winname"
TMUX_PANE="$TARGET_PANE" launch "$deep"
[ "$(head -1 "$LAUNCH_DIR/winname")" = "${long:0:30}-2" ] \
|| { echo "window: $(head -1 "$LAUNCH_DIR/winname")"; return 1; }
rm -rf "$PROJ"
}
@test "the launching tmux window is named for the session and handed back after" {
# tmux's own navigation - choose-window, the status line, the window list -
# shows a window name, and for a dozen sandboxes it showed a dozen copies
# of whatever automatic-rename made of the container command.
stub_launch
start_tmux
stub_window_probe
TMUX_PANE="$TARGET_PANE" launch --name 'release notes' "$PROJ"
grep -qxF 'release notes' "$LAUNCH_DIR/winname" \
|| { echo "window was named: $(cat "$LAUNCH_DIR/winname")"; return 1; }
# Handed back, so the window does not keep the name of a session that has
# exited. The option is the assertion rather than the name: tmux recomputes
# the name on its own schedule, but turning the option back on is what this
# does and what makes the recompute happen at all.
[ "$(tmux display-message -p -t "$TARGET_PANE" '#{automatic-rename}')" = 1 ] \
|| { echo "automatic-rename was left off"; return 1; }
# Without --name the project stands in, which is still better than the
# container command for finding one window among many.
: > "$LAUNCH_DIR/winname"
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
grep -qxF "$(basename "$PROJ")" "$LAUNCH_DIR/winname" \
|| { echo "window was named: $(cat "$LAUNCH_DIR/winname")"; return 1; }
# A window the operator named by hand keeps that name. tmux turns
# automatic-rename off on rename, so the option is the evidence that the
# current name is tmux's guess rather than a choice to leave alone.
tmux rename-window -t "$TARGET_PANE" 'mine'
: > "$LAUNCH_DIR/winname"
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
grep -qxF 'mine' "$LAUNCH_DIR/winname" \
|| { echo "the operator's own window name was taken: $(cat "$LAUNCH_DIR/winname")"; return 1; }
# But an explicit --name was typed for this launch, so it wins over that.
: > "$LAUNCH_DIR/winname"
TMUX_PANE="$TARGET_PANE" launch --name 'explicit' "$PROJ"
grep -qxF 'explicit' "$LAUNCH_DIR/winname" \
|| { echo "--name lost to a hand-named window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
rm -rf "$PROJ"
}
# The level the hook would have written, for a session this suite registered
# by hand. $STATE_DIR/notify/<run-id>/level is the host's shape, not the
# session's, so writing it here is standing in for the hook and not for a
# sandbox reaching somewhere it should not.
write_level() { # <run-id> <state>
mkdir -p "$RUNTIME_DIR/dejima/notify/$1/level"
: > "$RUNTIME_DIR/dejima/notify/$1/level/$2"
}
@test "docker is asked twice for a whole listing, whatever it holds" {
# It used to run `docker ps` and then inspect containers until it found a
# match, per registry entry: quadratic in the number of sessions, on the
# listing both front doors open on. A dozen parallel sessions is the case
# this tool exists for, so the cost has to be linear in them.
write_remote_config
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<EOF
#!/bin/bash
echo "\$1" >> "$BATS_TEST_TMPDIR/docker-calls"
case "\$1" in
ps) ls "$RUNTIME_DIR/dejima/sessions" | sed 's/^/c-/' ;;
# Every name it was handed, one line each: dejima-attach asks about all
# of them in one call, so a stub answering only for the last would
# silently prune every other session.
inspect)
for a in "\$@"; do
case "\$a" in
c-*) printf '"/%s" ["DEJIMA_RUN_ID=%s"]\n' "\$a" "\${a#c-}" ;;
esac
done ;;
esac
exit 0
EOF
chmod +x "$STUB_DIR/docker"
PATH="$STUB_DIR:$PATH"
: > "$BATS_TEST_TMPDIR/docker-calls"
local i
for i in 1 2 3 4; do write_entry "000000000000000$i" '%1' "work:$i"; done
run "$REPO_ROOT/bin/dejima-attach" --list
[ "$status" -eq 0 ]
[ "$(grep -c '^ps$' "$BATS_TEST_TMPDIR/docker-calls")" = 1 ] \
|| { echo "docker ps ran $(grep -c '^ps$' "$BATS_TEST_TMPDIR/docker-calls") times"; return 1; }
# One for all of them, not one each and not one per session: this is two
# round trips to the daemon whatever the host is running, where it was one
# plus one per container, and before that one plus one per container per
# session.
[ "$(grep -c '^inspect$' "$BATS_TEST_TMPDIR/docker-calls")" = 1 ] \
|| { echo "inspect ran $(grep -c '^inspect$' "$BATS_TEST_TMPDIR/docker-calls") times"; return 1; }
# And it still answered: a listing that asks nothing would also be cheap.
[ "$(printf '%s\n' "$output" | wc -l)" = 4 ]
}
@test "the picker's row is narrow enough for the phone that reads it" {
# Five columns made a row about eighty characters wide, which wrapped onto
# a second line on a phone and put the state and the age underneath the
# NEXT session's number. The two that went are in --list, which is where a
# script reads them: the tmux label is a coordinate nobody has to act on
# now that picking a session goes to it, and every container id shares a
# prefix with every other.
write_remote_config
stub_docker_sessions
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1' \
$(( $(date +%s) - 7200 )) $$ 'release-notes'
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
[ "$status" -eq 0 ]
local row
row="$(sed 's/\x1b\[[0-9;]*m//g' <<< "$output" | grep -F release-notes)"
[ -n "$row" ] || { echo "no row for the session"; return 1; }
[ "${#row}" -le 52 ] || { echo "row is ${#row} columns: $row"; return 1; }
[[ "$row" != *"work:1"* ]] || { echo "the tmux label is still in the row: $row"; return 1; }
[[ "$row" != *"c-0123456789"* ]] || { echo "the container id is still in the row: $row"; return 1; }
# Two hours, not a hundred and twenty minutes: three characters is what
# makes the row fit, and the sum is one an operator should not have to do.
[[ "$row" == *"2h"* ]] || { echo "age reads: $row"; return 1; }
# Both are still in the listing a script reads, in the units it wants.
run "$REPO_ROOT/bin/dejima-attach" --list
[[ "$output" == *"work:1"* ]] || { echo "--list lost the label: $output"; return 1; }
[[ "$output" == *"120m"* ]] || { echo "--list lost the minutes: $output"; return 1; }
}
@test "the name column is what the terminal has room for, and says when it cut one" {
# A fixed width is wrong in both directions at once: a desktop has room for
# every name and would cut them anyway, and a narrow phone would still
# overflow and wrap, which is the failure this layout exists to avoid.
write_remote_config
stub_docker_sessions
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1' '' $$ 'flashpoint-ultimate-server'
local row
run script -qec "stty cols 70; echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
row="$(sed 's/\x1b\[[0-9;]*m//g' <<< "$output" | grep -F flashpoint)"
[[ "$row" == *"flashpoint-ultimate-server"* ]] \
|| { echo "a terminal with room still cut it: $row"; return 1; }
run script -qec "stty cols 50; echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
row="$(sed 's/\x1b\[[0-9;]*m//g' <<< "$output" | grep -F flashpoint | tr -d '\r')"
[[ "$row" != *"flashpoint-ultimate-server"* ]] \
|| { echo "the name was not cut to fit: $row"; return 1; }
# Marked, because a name quietly losing its last characters is worse than a
# short column: two projects can differ only past the cut.
[[ "$row" == *"~"* ]] || { echo "the cut was silent: $row"; return 1; }
# The dot is one column and three bytes, and ${#} counts bytes wherever
# the locale is not UTF-8 - which is this suite. Swapped for one byte so
# the measurement is of columns, which is what has to fit.
row="${row//●/*}"
[ "${#row}" -le 50 ] || { echo "row is ${#row} columns on a 50-column terminal: $row"; return 1; }
}
@test "a session still building reaches the picker as starting, not as attachable" {
# It was the container column that said so, and that column is gone. A
# session whose image is still being built is not one to attach to yet.
write_remote_config
stub_docker_run_id ffffffffffffffff
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1' '' $$ 'building'
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
local row
row="$(sed 's/\x1b\[[0-9;]*m//g' <<< "$output" | grep -F building)"
[[ "$row" == *"starting"* ]] || { echo "row reads: $row"; return 1; }
[[ "$row" != *"attachable"* ]] || { echo "row reads: $row"; return 1; }
}
@test "an empty field does not shift the picker's columns" {
# A tab is IFS whitespace, so a run of them collapses into one delimiter.
# An entry written before the name field existed has an empty one, and the
# picker then read the tmux label as the name, the state as the age and the
# container id as the state - so a stale session was neither coloured as
# one nor labelled stale where it was being read.
write_remote_config
stub_docker_sessions
start_tmux
write_entry 0123456789abcdef '%99999' 'work:1'
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
[ "$status" -eq 0 ]
local plain; plain="$(sed 's/\x1b\[[0-9;]*m//g' <<< "$output")"
# The project is the first thing after the index and the dot, where the
# label used to land.
# [^[:space:]]+ for the dot rather than `.`, which matches one BYTE of a
# three-byte glyph outside a UTF-8 locale.
grep -qE '^[[:space:]]*1[[:space:]]+[^[:space:]]+[[:space:]]+myproject' <<< "$plain" \
|| { echo "row: $(grep -F myproject <<< "$plain")"; return 1; }
# And the state is in the state column, which is what colours it: under the
# shift this was the age column's muted grey.
[[ "$output" == *$'\033[91m'"stale"* ]] \
|| { echo "stale was not coloured as a failure state"; return 1; }
}
@test "the listing says which sessions want attention and which are working" {
# The bouncing-between-windows problem: every row said "attachable",
# which is true of all of them and the answer to a question nobody asked.
write_remote_config
stub_docker_sessions
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1' $(( $(date +%s) - 300 ))
write_entry fedcba9876543210 "$TARGET_PANE" 'work:2' $(( $(date +%s) - 200 ))
write_entry 00112233445566aa "$TARGET_PANE" 'work:3' $(( $(date +%s) - 100 ))
write_level 0123456789abcdef done
write_level fedcba9876543210 running
run "$REPO_ROOT/bin/dejima-attach" --list
[ "$status" -eq 0 ]
[[ "$output" == *"0123456789abcdef"*$'\t'"waiting"$'\t'* ]] \
|| { echo "stopped session: $output"; return 1; }
[[ "$output" == *"fedcba9876543210"*$'\t'"working"$'\t'* ]] \
|| { echo "running session: $output"; return 1; }
# Said nothing yet, so the plain reachability answer stands. A harness that
# reports no state at all has to keep working, and so does a session that
# started before its first prompt.
[[ "$output" == *"00112233445566aa"*$'\t'"attachable"$'\t'* ]] \
|| { echo "silent session: $output"; return 1; }
# waiting is the other half of the same question, for a harness that can
# say it: both render as one word, because an operator acts on them alike.
rm "$RUNTIME_DIR/dejima/notify/0123456789abcdef/level/done"
write_level 0123456789abcdef waiting
run "$REPO_ROOT/bin/dejima-attach" --list
[[ "$output" == *"waiting"* ]] || { echo "$output"; return 1; }
# The hook clears the siblings before writing, so holding two levels at
# once means a session has contradicted itself - which it can, the
# directory being its own. The answer that sends an operator to look is the
# one to be wrong with.
write_level 00112233445566aa running
write_level 00112233445566aa done
run "$REPO_ROOT/bin/dejima-attach" --list
[[ "$output" == *"00112233445566aa"*$'\t'"waiting"$'\t'* ]] \
|| { echo "contradicting session: $output"; return 1; }
rm "$RUNTIME_DIR/dejima/notify/00112233445566aa/level/done" \
"$RUNTIME_DIR/dejima/notify/00112233445566aa/level/running"
# A session that cannot be attached to has nothing useful to say about
# whether it wants attention, so reachability still wins the column.
write_entry ffeeddccbbaa9988 '%99999' 'work:9' $(( $(date +%s) - 50 ))
write_level ffeeddccbbaa9988 done
run "$REPO_ROOT/bin/dejima-attach" --list
[[ "$output" == *"ffeeddccbbaa9988"*$'\t'"stale"$'\t'* ]] \
|| { echo "stale session: $output"; return 1; }
# Oldest first, and by start time rather than by state: the numbers are how
# a session is picked, so an order that followed the state would renumber
# the rows between an operator reading one and typing it.
run bash -c "'$REPO_ROOT/bin/dejima-attach' --list | cut -f1 | tr '\n' ' '"
[ "$output" = "0123456789abcdef fedcba9876543210 00112233445566aa ffeeddccbbaa9988 " ] \
|| { echo "order was: $output"; return 1; }
}
@test "--count-waiting counts the sessions an operator has to go to, and asks docker nothing" {
# For a tmux status line, which re-runs it every few seconds: the point of
# the number is to not have to open anything, so it must not cost a round
# trip per container to produce.
write_remote_config
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<EOF
#!/bin/bash
echo "\$1" >> "$BATS_TEST_TMPDIR/docker-calls"
exit 1
EOF
chmod +x "$STUB_DIR/docker"
PATH="$STUB_DIR:$PATH"
: > "$BATS_TEST_TMPDIR/docker-calls"
start_tmux
# Two waiting, one working, one waiting but unreachable: the last is a
# session nothing can be done about, and counting it sends an operator
# looking for a window that is not there.
write_entry 0000000000000001 "$TARGET_PANE" 'work:1' '' $$
write_entry 0000000000000002 "$TARGET_PANE" 'work:2' '' $$
write_entry 0000000000000003 "$TARGET_PANE" 'work:3' '' $$
write_entry 0000000000000004 '%99999' 'work:4' '' $$
write_level 0000000000000001 done
write_level 0000000000000002 waiting
write_level 0000000000000003 running
write_level 0000000000000004 done
run "$REPO_ROOT/bin/dejima-attach" --count-waiting
[ "$status" -eq 0 ]
[ "$output" = 2 ] || { echo "counted $output"; return 1; }
[ ! -s "$BATS_TEST_TMPDIR/docker-calls" ] \
|| { echo "docker was asked: $(cat "$BATS_TEST_TMPDIR/docker-calls")"; return 1; }
# And a dead launcher is not waiting for anyone.
write_entry 0000000000000005 "$TARGET_PANE" 'work:5' '' "$(dead_pid)"
write_level 0000000000000005 done
run "$REPO_ROOT/bin/dejima-attach" --count-waiting
[ "$output" = 2 ] || { echo "counted $output with a dead launcher"; return 1; }
}
@test "--counts answers for every state in one pass, and asks docker nothing" {
# Two numbers on a status bar cost two invocations otherwise, and this is
# the one thing here that runs on a timer.
write_remote_config
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<EOF
#!/bin/bash
echo "\$1" >> "$BATS_TEST_TMPDIR/docker-calls"
exit 1
EOF
chmod +x "$STUB_DIR/docker"
PATH="$STUB_DIR:$PATH"
: > "$BATS_TEST_TMPDIR/docker-calls"
start_tmux
write_entry 0000000000000001 "$TARGET_PANE" 'work:1' '' $$
write_entry 0000000000000002 "$TARGET_PANE" 'work:2' '' $$
write_entry 0000000000000003 "$TARGET_PANE" 'work:3' '' $$
write_entry 0000000000000004 '%99999' 'work:4' '' $$
write_entry 0000000000000005 '' '' '' $$
write_level 0000000000000001 done
write_level 0000000000000002 running
run "$REPO_ROOT/bin/dejima-attach" --counts
[ "$status" -eq 0 ]
[ "$output" = "waiting=1 working=1 attachable=1 stale=1 no-tmux=1 total=5" ] \
|| { echo "counts read: $output"; return 1; }
[ ! -s "$BATS_TEST_TMPDIR/docker-calls" ] \
|| { echo "docker was asked: $(cat "$BATS_TEST_TMPDIR/docker-calls")"; return 1; }
# The words are the picker's, so a number on a status bar and a row on a
# screen say the same thing about the same session.
run "$REPO_ROOT/bin/dejima-attach" --list
[[ "$output" == *"waiting"* ]] && [[ "$output" == *"no-tmux"* ]] \
|| { echo "the listing uses different words: $output"; return 1; }
# A session whose launcher is gone is not counted at all: the listing is
# where those are found, and a status bar that never reaches zero stops
# being read.
write_entry 0000000000000006 "$TARGET_PANE" 'work:6' '' "$(dead_pid)"
write_level 0000000000000006 done
run "$REPO_ROOT/bin/dejima-attach" --counts
[ "$output" = "waiting=1 working=1 attachable=1 stale=1 no-tmux=1 total=5" ] \
|| { echo "a dead launcher was counted: $output"; return 1; }
}
@test "--state-dir names the directory the registry is actually in" {
# For anything on this host that wants to read the states without
# re-deriving which of XDG_RUNTIME_DIR and /tmp this user got.
write_remote_config
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
run "$REPO_ROOT/bin/dejima-attach" --state-dir
[ "$status" -eq 0 ]
[ -d "$output" ] || { echo "not a directory: $output"; return 1; }
[ -f "$output/sessions/0123456789abcdef" ] \
|| { echo "the registry is not under $output"; return 1; }
# And the levels, which is the half an outside reader is actually after.
write_level 0123456789abcdef running
[ -e "$output/notify/0123456789abcdef/level/running" ] \
|| { echo "the levels are not under $output"; return 1; }
}
@test "--watch redraws the list until something is typed" {
write_remote_config
stub_docker_sessions
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
# Nothing typed for longer than one interval, then a quit. Two drawings of
# the heading is the assertion: the one-shot picker would print it once and
# then sit on a read that never returns.
run script -qec "{ sleep 8; echo q; } | '$REPO_ROOT/bin/dejima-attach' --watch" /dev/null
[ "$status" -eq 0 ]
[ "$(grep -c 'live sessions' <<< "$output")" -ge 2 ] \
|| { echo "drew $(grep -c 'live sessions' <<< "$output") times"; return 1; }
# And it is still the picker: a number attaches rather than being read as
# part of the next redraw. Nothing here can attach - there is no terminal
# for it - but reaching the attempt at all is what distinguishes picking
# from the prompt timing out again.
run script -qec "echo 1 | '$REPO_ROOT/bin/dejima-attach' --watch" /dev/null
[[ "$output" == *"live sessions"* ]]
[ "$(grep -c 'live sessions' <<< "$output")" = 1 ] \
|| { echo "a typed choice did not end the loop"; return 1; }
}
@test "a session's name reaches both the listing and the picker" {
# The case the name exists for: two sessions, one workspace, so the
# project column answers nothing. --list carries both fields; the picker
# has one column for the two and spends it on whichever is more specific.
write_remote_config
stub_docker_sessions
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1' '' '' 'release notes'
write_entry fedcba9876543210 "$TARGET_PANE" 'work:2'
run "$REPO_ROOT/bin/dejima-attach" --list
[ "$status" -eq 0 ]
[[ "$output" == *"0123456789abcdef"$'\t'"release notes"$'\t'"work:1"$'\t'"myproject"$'\t'* ]] \
|| { echo "named row: $output"; return 1; }
# An entry from before the field existed: present and empty rather than
# absent, so a reader splitting on tabs does not find the project where
# the name should be. A launch always writes one now.
[[ "$output" == *"fedcba9876543210"$'\t'$'\t'"work:2"$'\t'"myproject"$'\t'* ]] \
|| { echo "unnamed row: $output"; return 1; }
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
[ "$status" -eq 0 ]
[[ "$output" == *"release notes"* ]] \
|| { echo "the picker dropped the name: $output"; return 1; }
[[ "$output" == *"myproject"* ]] \
|| { echo "the picker dropped the unnamed row's project: $output"; return 1; }
}
@test "--list marks a session whose pane is gone as stale" {
write_remote_config
stub_docker_run_id 0123456789abcdef
@@ -735,7 +1299,15 @@ stub_docker_sessions() {
#!/bin/bash
case "\$1" in
ps) ls "$RUNTIME_DIR/dejima/sessions" | sed 's/^/c-/' ;;
inspect) name="\${!#}"; echo "DEJIMA_RUN_ID=\${name#c-}" ;;
# Every name it was handed, one line each: dejima-attach asks about all
# of them in one call, so a stub answering only for the last would
# silently prune every other session.
inspect)
for a in "\$@"; do
case "\$a" in
c-*) printf '"/%s" ["DEJIMA_RUN_ID=%s"]\n' "\$a" "\${a#c-}" ;;
esac
done ;;
esac
exit 0
EOF
@@ -865,6 +1437,46 @@ start_tmux() {
done
}
@test "inside tmux the picker switches the client instead of nesting a session" {
# tmux refuses to attach a session from inside one of its own panes, so on
# the operator's own machine the picker could list every session and then
# reach none of them. Switching is also what makes it a window switcher,
# which is the thing a dozen parallel sessions actually need.
write_remote_config
stub_docker_run_id 0123456789abcdef
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
# A real client, because switch-client has nothing to move without one,
# and it starts on window 0 while the session being picked is window 1.
script -qec "tmux attach -t work" /dev/null >/dev/null 2>&1 &
client_pid=$!
for _ in $(seq 100); do
[ -n "$(tmux list-clients -F '#{client_session}' 2>/dev/null)" ] && break
sleep 0.1
done
tmux select-window -t work:0
[ "$(tmux display-message -p -t work '#{window_index}')" = "0" ]
# Run from inside a pane of that session, which is how an operator reaches
# it and the only way $TMUX is set for the right server.
tmux new-window -t work -d \
"'$REPO_ROOT/bin/dejima-attach' 0123456789abcdef; touch '$BATS_TEST_TMPDIR/switched'"
for _ in $(seq 100); do
[ -e "$BATS_TEST_TMPDIR/switched" ] && break
sleep 0.1
done
[ -e "$BATS_TEST_TMPDIR/switched" ] || { echo "dejima-attach never returned"; return 1; }
# The client moved to the sandbox's own window, and no throwaway session
# was created to do it.
[ "$(tmux display-message -p -t work '#{window_index}')" = "1" ] \
|| { echo "client is on window $(tmux display-message -p -t work '#{window_index}')"; return 1; }
run tmux has-session -t '=dejima-0123456789abcdef'
[ "$status" -ne 0 ] || { echo "a grouped session was created anyway"; return 1; }
kill "$client_pid" 2>/dev/null || true
}
@test "attaching creates a grouped session on the requested window and disposes of it on detach" {
# The positive direction, which is where every interesting tmux mistake
# lives: an invalid set-option target, or setting destroy-unattached while
@@ -1371,6 +1983,46 @@ EOF
rm -rf "$NOTIFY" "$payload_file"
}
@test "the notify hook keeps a level beside the edge, and only the edge is pushable" {
# The edge markers are consumed by the watcher, so after a push there is
# nothing left to read - and with no watcher running, nothing has read them
# at all. The level answers "which state is this session in now" for the
# picker and the status line, which is a different question from "it just
# changed" and needs an answer that survives being read.
NOTIFY="$(mktemp -d)"
mkdir "$NOTIFY/level"
# `running` is level-only: the watcher iterates waiting and done, so an
# edge marker it never looks for would be a file nothing ever removes.
DEJIMA_NOTIFY_DIR="$NOTIFY" run "$REPO_ROOT/hooks/dejima-notify.sh" running
[ "$status" -eq 0 ]
[ "$(ls -A "$NOTIFY" | sort | tr '\n' ' ')" = "level " ] \
|| { echo "running left an edge marker: $(ls -A "$NOTIFY")"; return 1; }
[ "$(ls -A "$NOTIFY/level")" = running ]
[ ! -s "$NOTIFY/level/running" ]
# Exactly one level at a time, so a reader never has to choose.
DEJIMA_NOTIFY_DIR="$NOTIFY" run "$REPO_ROOT/hooks/dejima-notify.sh" done
[ "$status" -eq 0 ]
[ "$(ls -A "$NOTIFY/level")" = done ] \
|| { echo "level holds: $(ls -A "$NOTIFY/level")"; return 1; }
[ -f "$NOTIFY/done" ] || { echo "done left no edge marker"; return 1; }
# The same whitelist covers the level: a name outside the enum writes
# neither, and leaves the state that was there.
DEJIMA_NOTIFY_DIR="$NOTIFY" run "$REPO_ROOT/hooks/dejima-notify.sh" 'running;done'
[ "$status" -eq 0 ]
[ "$(ls -A "$NOTIFY/level")" = done ]
# A run directory with no level/ is a launch from before it existed. The
# edge still works there, which is what keeps notifications independent.
OLD="$(mktemp -d)"
DEJIMA_NOTIFY_DIR="$OLD" run "$REPO_ROOT/hooks/dejima-notify.sh" waiting
[ "$status" -eq 0 ]
[ -f "$OLD/waiting" ]
rm -rf "$NOTIFY" "$OLD"
}
@test "the notify hook refuses any event name outside the enum" {
NOTIFY="$(mktemp -d)"
for bad in 'error' '../escape' 'waiting done' '' '$(id)' 'waiting;done' '.'; do
@@ -1419,6 +2071,21 @@ EOF
run jq -r '[.hooks[][].hooks[].command] | join(" ")' "$LAUNCH_DIR/settings-copy.json"
[[ "$output" != *operator-desktop-notify.sh* ]]
[[ "$output" != *operator-tts.sh* ]]
# The complement of Stop, for the level and not for the phone: without it
# a picker cannot tell "this session has stopped" from "nothing has been
# heard from this session". It pushes nothing, because the hook writes no
# edge marker for `running`.
grep -q 'dejima-notify.sh running' "$LAUNCH_DIR/settings-copy.json" \
|| { echo "nothing marks the session as working again"; return 1; }
[ "$(jq -r '.hooks.UserPromptSubmit[0].hooks[0].command' "$LAUNCH_DIR/settings-copy.json")" \
= "/usr/local/lib/dejima/hooks/dejima-notify.sh running" ]
# And the directory the hook writes that level into is the host's to
# create, like the marker directory it sits in. Observed from inside the
# run, because the EXIT trap takes the whole tree with it.
grep -qxF level "$LAUNCH_DIR/level-probe" \
|| { echo "no level directory was created inside the marker directory"; return 1; }
rm -rf "$PROJ"
}
+18
View File
@@ -48,6 +48,24 @@ so they cannot quietly stop running.
The rest — gestures, the viewport manager, the toolbar — needs a real browser
and a real phone. `docs/remote-access.md` has the manual checklist.
One of them is automated, against a real browser and a mock ttyd:
```sh
node test/browser/sess-button.mjs
```
It drives the built page in Chromium and proves the thing the unit tests
cannot: that SESS reaches the picker on a new WebSocket, without the page
being fetched or run a second time. Three seconds, and it needs Playwright,
which a dejima sandbox has and this package deliberately does not depend on -
a browser is a strange thing to install to inline two files.
Deliberately not part of `npm test`, which CI runs in a `node:20-bookworm`
container with no browser in it — a test that cannot run there would have to
be skipped, and a skipped test is one nobody is reading. What keeps it from
rotting instead is [docs/release.md](../docs/release.md), whose manual list
names it for any release that touches `web/`.
## Layout
| File | Role |
+65 -3
View File
@@ -14,7 +14,7 @@
no path a /favicon.ico could be fetched from, so a link to one would 404 and
the tab would fall back to the generic icon. -->
<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGUAAABlCAYAAABUfC3PAAAACXBIWXMAAAsSAAALEgHS3X78AAAK9UlEQVR4nO2db0wb5x3Hv6BAa2w0ggcjJlHsOHYkYgs8oIFIThMsUTlCJJsEFW/CgjJpaFsi7VWFtBfTVNZXUYJU5Q1K11ckIK0KQ0JDcrTWVU0FFCMcpJxLMQoYhOuEyf+K3XV74dxxZ5/xnX1nH+E+b+D85+6xPzzP7+653++hBAACxNRlyEgBd42xa7ckQEx9BuB6sVsjAwDYBaArhSxESlQBaCotditk0pGlSBBZigSRpUgQWYoEkaVIEFmKBJGlSBBZigSRpUgQWYoEkaVIkGPFbgBXvGt+hCMxuJdXAQBbO6+wvfOS9bUqpQIGnQYAcFangUqlgMWkL1hb80WSUrxrfriXV+Fd8+Pb7zbhXfPz3odz1pP2WF1tNQxnNDDoNGgy6yUrqiRATP2v2I0gJSx6Vlm/TDEx6DSwtpmkJOlK0aR41/yYdszBOfss4zBEp1KtRKW6AupTVShXlFHbbOzFEgi+2AUAbBEBxvZBqJQKWNtMsF44D4tZD5VSwe9DCUNhpXAVUV5RBo2xBuqTVcmfr0XkyxYRwPcbuwi++A+2iABCwciBr7fbWmG9cB7WNlPex+aB+FLCkRimHfMYn3QeKELbpMEJYw0loxCEglFsEQH4iQB8S5uIRxOsr1MpFbDbWtDbfQl1tcfFbpZ4UhY9q5h2zGPaMcf6fHlFGbSN9dA2aaAx1gjSE/JliwjAt+SHz+3P2IssZj3sHa2w21rEaobwUqYd85iY/CLjGZO2SQNtYz2M7aeFPKzgbBEBPHetZ+xBdbXVuGprQU+3VejYI5yUacc8Ho7NsA5RlWolTLazONeulUSP4AvhWgfh8sFPBNKeUykV6O22wm5rFWpoy1/KxKQTD8dmEI7E0p7TNmlg7jDghLEmn0NIhlAwioWplYy9x25rxe1b3fn2nNylZOoZZKxo7mrIeMp62InHElh2eEG41tNiD9lz8hjW+EtZ9Kxi+N5jVhnmDgPMNsOhHKJyhXCtY2FqhVXOQF8nerqtfHfJXcr2ziuMjD5Ju+I+qjJSySTHoNPgj7+9xme2gJuUTHHDbDOguavhSMtIZfmpFwtTK2kxp6fbioG+Ti5DWnYp0455DN9/xHhMY6zBu/2tb2zMyJd4LIGFqRUsO7yMx+22VgzdeT/b269knSVO7R2dgxehbdTwbeeRolxRhvaeRhjbT2PmgYsa0tjOUNnIepMrdd5H7h3cUZ+swl4sTm1bL3CbQ8sqpa72OEPMsuPbHJp3NCFc61RsIefPuMDpdrC9Y39nhMuHeIx94k6GCeHyUb/zmSvjJMXaZkJdbTW1/Zx2MBl2QsEoY1qmt/sS5/dyTpy4SjPtkYewrHie7p95Wcx6XvNinKXYba3U76FgBFssk3MySeKxBGM0sXe0ZnwtG5yl1NUeZ4h57lrndaCjhM/tzynAk/DK+6LvXA74mVmmDV0qpQLTjnle7+clxWLSywE/C8GNXUaSxvbOSwzffwR73585Z+rwzpDspc16ygE/nUzXceFIDCOjk5z2wVsKfQiTAz6TeCwB39Imtf3BL3/AjXN71Pb2zktOQxlvKcnAJQd8NugBvl75E24Y9/CB5Qdc1+1PtTwcm8m6n5wSvOWAzw49wF/X7X8nv6L9ziXxMCcpqQE/dYr6KJIa4Om947O1/ftNXBL7ci6FoAd8Qh7CGAHedjKBeuVPAIBQogSOzX0p9HnETOQsJTXg+5b4Z8a/KaQGePrQ5dgoQyheAiCZK8alp+RcCkEGfDIDknD5CnLzK7ixi3g0gVAwmjGLsbyiDD9/nfpaiPSm5y4f4/bvZqQUm5FSzO0cw8eet6jHr3K8ss+rPqWn20pJSaZ6RgW9CUbP9U0ds/lQqVZCfepnouUqp16vffTN2/jom7fTXkc/az2IvKQYdMkCHDJFlXD50NzVkM8uEdzYBeFaPzCfly+hYCQ5xLqT7aTnMefbu7lk7wPJP2CuM8V5V3L1dF+iEisI13pOUshZVcK1zrk3vFP7I1pr/8v63GYkGVzJsTzteNEECJcPhMuH8ooynGvXwtRhyKmX06/TLGY9rBdMVIVBMoac552tn7cUa9t5qEYVCEdiVMDn+teXLQ0USH5Qi0mPJrMet4ceUI//3rSH1tof2febcsZz+9Y1AMlEwsXlVUYCQzyazHZcdnh5p9nGYwnG3cWbfZ2wmPS5JOAxyFsKWf3EJ+BvEQEsP/VSw0kqyWoqU1I4LU/K2maiJvX+8GUF/vFemDr1JAklSjD0tYLqJSqlgvqSyJ/OWQ+cXz+Dc9bDEORzJ8sg1KeqYO4wZK0MoE/I1tVWC1aeJ0ghKteAv0UEsDC1wpq9TpYWHJS9frOvk5ISipfg1/9S4YYxjnde95jNSCk+9ryFzcj+mf5AX2fafqxtJljbTAjf6oZz9lla6UbwxS7+/ekcFqZW0NzVkFEOPcD35tk76AhWCjFw5y71wZq7Ghix5SAZfItw2JIDM8Ex+Q3AwUVOlWplmpwtIoB/3v18v11jfxWqTkXY+hTyyyqvKMNv7l7LKoMcg/ninPVg+P7jA5PbBvo6cZOll2Rje+cVHo7NHChH26TBV+NLVDzhI58DwkkJR2LouTVMfVHlFWUZazjstpa8x1+ylnLaMUf1UCFrE7d3XmF88gtMO+bT5Kd+tpHhQSHLvYUtrxu+/zhjjaPd1oqBvk7BCzkXPavUWZnFrMfIh4OC7j8ciWFi0onxSSdrz6yrrcbE6JCQh8yeS8wHesAnEUtGoVApFbj5us6ETY6QAZ5EUCkGnQY93Va4l1dx9kz9oZaRSqoc56wHdb+oFqVKWPC1WcgLtTcVUk4uJxFckZeWkiCyFAkiS5EgshQJIon1vrgQjsSodcHoq+J5v/MzTlEt5uRFHLk63lmdBoYz9YfpLLB4631xwbvmx5ezHjhnPTmtjkeHvLdhMekLvVQUX6QnJRyJsc7cCkmuN58KhHSkZJvOAJITgieMNahUV0Dz+kaUirZCXpy2At73G7sIB6MIvthlnRAlkeCMgzSkZFt0R9tYT8nIhXgskVxoze3PeJeTx+IDYlNcKd41P/5271HaMEUuLWJs1wpeIh6PJeBz+zOupzJ05/1ix5ziSZl2zGNk9AmjdxR6nRffkh+u8aU0OUXuNcWRwjbFX8x1XtjWUzHoNBgZHiyGmMJKCUdiuD30gDFclVeU4b3fXSz6Qm2hYBSffzrHOClQKRUYGR6kVgMvEIWTwiZEY6xB5+BFSa2C5JpYYlQRFEHMlYJMs7AJMbZr0fWndyUlBADaexpxuX8/vZSt7WJTECnD9x+nCbncL9oSsnljbD+dJmbow79zXoUoX0SX8snYDKMqVupCSFLFbO+8ZGRoiomoUhY9q4wav8MihCRVjHfNj5HRJ6IfVzQpZJcnUZ+qwsXeRrEOJxrG9tMwtmup7YlJJxY9q6IeUzQpI6OTjBywy/0tkgvqXLnc3wL1qf2aluF7j0U9nihSkimg+xeHzV0NBfunAmJBH3a3d17iEw6l17kiihR6gzXGGpg7DGIcpqCoT1Yx8qMPms3OF8FTjMgaEJJQMIopWiL0YWaPtl4AeatBjFQjwaVMTDoZ22Rp25vIuEhSBB++CjxPVFQMZ8T5rKLMfYl9yigVDDqNGLPIwiZ4k0jkv8AdWuS8LwkiS5EgshQJIkuRILIUCSJLkSCyFAkiS5EgshQJIkuRILIUCVIKwFfsRsgw2D0G4C8A+ovdEhkAgLvG2OX+Pzk3fgySn1OHAAAAAElFTkSuQmCC">
<!-- Generated by web/build.mjs - do not edit. Sources: 0cc964e9a08f82488c268b4e95b19542a42c5ada3b927e7fac62f7acc46bde94 -->
<!-- Generated by web/build.mjs - do not edit. Sources: c43f6331189abbbe08955f8548a631132008ba6051a6274088be6f85c0f7d2a4 -->
<style>
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
@@ -1011,6 +1011,7 @@ body.mono-keys {
// onStatus(string), size() -> {cols, rows}}
function createClient(opts) {
var urls = endpoints(opts.location);
var location = opts.location;
var encoder = new TextEncoder();
var decoder = new TextDecoder();
var socket = null;
@@ -1108,9 +1109,45 @@ body.mono-keys {
};
}
// Point the connection at a different ttyd argument. ttyd reads --url-arg
// off the WebSocket request rather than off the page load, so a different
// command is a different socket and nothing else: the half a megabyte of
// terminal this page is does not have to be fetched, parsed and run again
// to reach one.
//
// The old socket's handlers are detached before it is closed, rather than
// a flag being read inside them. onclose is delivered asynchronously, so a
// handler left attached can arrive after the replacement socket exists and
// null the field holding it - and the reconnect it would schedule is for
// the url this call just replaced.
function switchTo(search) {
urls = endpoints({
protocol: location.protocol,
host: location.host,
pathname: location.pathname,
search: search
});
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
backoff = RECONNECT_MIN_MS;
if (socket) {
socket.onclose = null;
socket.onmessage = null;
socket.onerror = null;
socket.close();
socket = null;
}
connect();
}
return {
connect: connect,
urls: urls,
switchTo: switchTo,
// A function, not the object: switchTo replaces it, and a copy taken
// here would answer for the connection this client used to have.
urls: function () { return urls; },
isOpen: open,
sendInput: sendInput,
sendBinary: sendBinary,
@@ -1879,6 +1916,31 @@ body.mono-keys {
}
});
// The SESS button: back to dejima-attach with no run id, which is the
// picker. A new socket rather than a new page - ttyd takes the run id as a
// WebSocket argument, so the command changes without the half a megabyte of
// inlined terminal being fetched, parsed and run a second time. On a phone
// that was most of the wait.
//
// The terminal is reset first, and the mode tracker with it. What the socket
// carries away is the process, not the state it left in here: an alternate
// screen, mouse reporting, a cursor key mode. The picker would otherwise
// open into whatever the session it replaced had switched on, and the
// gestures would still be sending mouse reports to something that is not
// asking for them.
//
// replaceState rather than a history entry: the address bar has to stop
// saying ?arg=<run-id> or a reload would reopen the session just left, and
// going "back" to a session that ended is not a thing to offer.
function goToPicker(search) {
term.reset();
mouse.modes.clear();
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', window.location.pathname + search);
}
client.switchTo(search);
}
// Sticky Ctrl is applied here rather than in attachCustomKeyEventHandler
// because the target is a phone: soft keyboards overwhelmingly report
// keydown as "Unidentified" and deliver the real character through an input
@@ -1938,7 +2000,7 @@ body.mono-keys {
prefix: function () { return prefixByte; },
notify: notify,
search: function () { return window.location.search; },
navigate: function (search) { window.location.search = search; },
navigate: goToPicker,
allowKeyboard: keyboard.allow
});
toolbar.attach();
+2 -2
View File
@@ -5,12 +5,12 @@ f6f6611d0b3eacc39d10add9c4bbc56d4b3a70a47f022351a1c6eab68cd66e71 package-lock.j
f690fd9d35975bc977e3d323595a2a4aa1fca6ed50f213602f08adec3f5b2c92 src/gestures.js
e75dc9d5e311a3d9bf9dc373b651ad506afbd7a7cc7ad54aac6fdad94276b0a4 src/keyboard.js
2a070f63702b95b0c62963ec6b9ec4fa2b0b89b7553a8a889d68cdf3506f9ce3 src/keys.js
e5888ba8413fc2b442b44cade378f7393fd231dd5f79d44c58d1bc217ed7c807 src/main.js
05bc2e446c3baa57422c8fa036939a0df8cf35ac0cf02bb35233befafae5667f src/main.js
e3a550e2249b536c9bc3d90ee2735a9a2d8e6f772a6a631f724d289b96b1f557 src/modes.js
8446be09a1eca041a5a2e2068fdace32596713aef1d1fb74b522ea4eacc50ec3 src/mouse.js
cb5e33c0fa117176d9073bd9019f78b65bfe5535833bf1765ee542c700d67f1f src/osc52.js
94229c981f6287162d6ef774f71ef41d941494595f76f16bb742811ca32a767f src/page.css
e02823ce9d5d57615a0a8c0c7b91611932202758048b7eadabe4f1054aaf1a9f src/protocol.js
2490ec55a050d993364a62f590b51e63945ea5b1276832821d6d1d0854fd5c81 src/protocol.js
aac82e88c6a65617818c6589091cbb8c3bab50e82e98aabf8b583cbb3dfda7e7 src/sgr.js
34e34a8d258b8a7d2afde03287681b99d839994936c398ba5c7165a3ed7aec93 src/template.html
3c8e5520b694693126097d9fe17f89ff23ba15adb8532521c647907edba20215 src/toolbar.js
+26 -1
View File
@@ -139,6 +139,31 @@
}
});
// The SESS button: back to dejima-attach with no run id, which is the
// picker. A new socket rather than a new page - ttyd takes the run id as a
// WebSocket argument, so the command changes without the half a megabyte of
// inlined terminal being fetched, parsed and run a second time. On a phone
// that was most of the wait.
//
// The terminal is reset first, and the mode tracker with it. What the socket
// carries away is the process, not the state it left in here: an alternate
// screen, mouse reporting, a cursor key mode. The picker would otherwise
// open into whatever the session it replaced had switched on, and the
// gestures would still be sending mouse reports to something that is not
// asking for them.
//
// replaceState rather than a history entry: the address bar has to stop
// saying ?arg=<run-id> or a reload would reopen the session just left, and
// going "back" to a session that ended is not a thing to offer.
function goToPicker(search) {
term.reset();
mouse.modes.clear();
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', window.location.pathname + search);
}
client.switchTo(search);
}
// Sticky Ctrl is applied here rather than in attachCustomKeyEventHandler
// because the target is a phone: soft keyboards overwhelmingly report
// keydown as "Unidentified" and deliver the real character through an input
@@ -198,7 +223,7 @@
prefix: function () { return prefixByte; },
notify: notify,
search: function () { return window.location.search; },
navigate: function (search) { window.location.search = search; },
navigate: goToPicker,
allowKeyboard: keyboard.allow
});
toolbar.attach();
+38 -1
View File
@@ -41,6 +41,7 @@
// onStatus(string), size() -> {cols, rows}}
function createClient(opts) {
var urls = endpoints(opts.location);
var location = opts.location;
var encoder = new TextEncoder();
var decoder = new TextDecoder();
var socket = null;
@@ -138,9 +139,45 @@
};
}
// Point the connection at a different ttyd argument. ttyd reads --url-arg
// off the WebSocket request rather than off the page load, so a different
// command is a different socket and nothing else: the half a megabyte of
// terminal this page is does not have to be fetched, parsed and run again
// to reach one.
//
// The old socket's handlers are detached before it is closed, rather than
// a flag being read inside them. onclose is delivered asynchronously, so a
// handler left attached can arrive after the replacement socket exists and
// null the field holding it - and the reconnect it would schedule is for
// the url this call just replaced.
function switchTo(search) {
urls = endpoints({
protocol: location.protocol,
host: location.host,
pathname: location.pathname,
search: search
});
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
backoff = RECONNECT_MIN_MS;
if (socket) {
socket.onclose = null;
socket.onmessage = null;
socket.onerror = null;
socket.close();
socket = null;
}
connect();
}
return {
connect: connect,
urls: urls,
switchTo: switchTo,
// A function, not the object: switchTo replaces it, and a copy taken
// here would answer for the connection this client used to have.
urls: function () { return urls; },
isOpen: open,
sendInput: sendInput,
sendBinary: sendBinary,
+115
View File
@@ -0,0 +1,115 @@
// Enough of ttyd to drive the real page: the three paths its HTTP handler
// answers, and a WebSocket that spawns nothing but speaks the frame protocol
// src/protocol.js was written against.
//
// Hand-rolled rather than a dependency, because web/ ships with two and both
// are the terminal itself. The frames here are the ones this page sends and
// receives and no others: a text handshake, masked client frames under 64KB,
// and unmasked server frames.
import http from 'node:http';
import crypto from 'node:crypto';
import fs from 'node:fs';
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
function accept(key) {
return crypto.createHash('sha1').update(key + GUID).digest('base64');
}
// A server frame: FIN set, binary opcode, no mask, two length forms.
function frame(payload) {
const len = payload.length;
const head = len < 126 ? Buffer.from([0x82, len])
: Buffer.concat([Buffer.from([0x82, 126]), Buffer.from([len >> 8, len & 0xff])]);
return Buffer.concat([head, payload]);
}
// One client frame off the front of a buffer, or null when it is not all here
// yet. Only the opcodes this page sends: text, binary and close.
function unframe(buf) {
if (buf.length < 2) return null;
const masked = (buf[1] & 0x80) !== 0;
let len = buf[1] & 0x7f;
let off = 2;
if (len === 126) {
if (buf.length < 4) return null;
len = buf.readUInt16BE(2);
off = 4;
}
const maskLen = masked ? 4 : 0;
if (buf.length < off + maskLen + len) return null;
const mask = masked ? buf.subarray(off, off + 4) : null;
const body = Buffer.from(buf.subarray(off + maskLen, off + maskLen + len));
if (mask) for (let i = 0; i < body.length; i++) body[i] ^= mask[i % 4];
return { opcode: buf[0] & 0x0f, body, rest: buf.subarray(off + maskLen + len) };
}
// opts.onConnect(url) -> the text this "session" prints. Returning different
// text for the picker and for a run id is what lets a test tell which command
// ttyd would have run.
export async function startMockTtyd(indexPath, opts) {
const index = fs.readFileSync(indexPath);
const pageLoads = [];
const sockets = [];
const server = http.createServer((req, res) => {
const path = req.url.split('?')[0];
if (path === '/token') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ token: '' }));
return;
}
if (path === '/' || path === '/index.html') {
pageLoads.push(req.url);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(index);
return;
}
res.writeHead(404);
res.end();
});
server.on('upgrade', (req, socket) => {
sockets.push({ url: req.url, closed: false, input: '' });
const mine = sockets[sockets.length - 1];
socket.write(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\nConnection: Upgrade\r\n' +
'Sec-WebSocket-Protocol: tty\r\n' +
'Sec-WebSocket-Accept: ' + accept(req.headers['sec-websocket-key']) + '\r\n\r\n');
let buf = Buffer.alloc(0);
let spawned = false;
socket.on('data', (chunk) => {
buf = Buffer.concat([buf, chunk]);
for (;;) {
const f = unframe(buf);
if (!f) break;
buf = f.rest;
if (f.opcode === 0x8) { socket.end(); return; }
// An input frame is '0' and then the bytes. Recorded so a test can see
// that what is typed after a switch reaches the new command rather
// than the socket that was replaced.
if (spawned && f.body.length > 1 && f.body[0] === 0x30) {
mine.input += f.body.subarray(1).toString();
}
// ttyd spawns the command when the first message - the one carrying
// the token and the size - arrives, and not before.
if (!spawned) {
spawned = true;
socket.write(frame(Buffer.concat([
Buffer.from('0'), Buffer.from(opts.onConnect(mine.url))
])));
}
}
});
socket.on('close', () => { mine.closed = true; });
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
return {
url: `http://127.0.0.1:${server.address().port}/`,
pageLoads,
sockets,
stop: () => new Promise((r) => server.close(r))
};
}
+94
View File
@@ -0,0 +1,94 @@
// What the SESS button does, against the real page in a real browser.
//
// Not part of `npm test`: it needs Chromium, which the CI image does not have
// and which `node --test` would fail on rather than skip. Run it by hand, on a
// machine with Playwright:
//
// node test/browser/sess-button.mjs
//
// It proves the one thing the unit tests cannot: that pressing SESS reaches
// the picker without the page being fetched and parsed a second time.
import { startMockTtyd } from './mock-ttyd.mjs';
// Playwright is not a dependency of this package and is deliberately not
// added as one: it would pull a browser into an install that exists to inline
// two files. Resolved where it is rather than where this happens to have been
// run - a dejima sandbox carries it at a fixed path, a host that has it has it
// on the module path.
async function loadChromium() {
for (const spec of ['playwright', '/opt/node-tools/lib/node_modules/playwright/index.mjs']) {
try {
return (await import(spec)).chromium;
} catch (e) { /* the next one, or the message below */ }
}
console.error('This check needs Playwright, which is not a dependency here.');
console.error('Run it inside a dejima sandbox, which has one, or install it:');
console.error(' npm i -g playwright && npx playwright install chromium');
process.exit(2);
}
const chromium = await loadChromium();
const RUN_ID = 'deadbeefdeadbeef';
const checks = [];
function check(name, ok, detail) {
checks.push({ name, ok, detail });
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok || !detail ? '' : ' <- ' + detail}`);
}
const ttyd = await startMockTtyd(new URL('../../index.html', import.meta.url).pathname, {
onConnect: (url) => (url.includes('arg=' + RUN_ID)
? 'SESSION ' + RUN_ID + ' attached\r\n'
: 'dejima | live sessions\r\n')
});
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(`${ttyd.url}?toolbar=1&arg=${RUN_ID}`);
await page.waitForFunction((id) => document.body.innerText.includes('SESSION ' + id), RUN_ID);
check('the run id reaches ttyd as a WebSocket argument',
ttyd.sockets.length === 1 && ttyd.sockets[0].url.includes('arg=' + RUN_ID),
JSON.stringify(ttyd.sockets.map((s) => s.url)));
const loadsBefore = ttyd.pageLoads.length;
// A marker on the window as well as the server's count: a reload served from
// the browser's cache would never reach the server, and this page is one big
// cacheable file.
await page.evaluate(() => { window.__survivedTheSwitch = true; });
await page.click('[data-action="sessions"]');
await page.waitForFunction(() => document.body.innerText.includes('live sessions'));
check('the page itself was never re-run',
await page.evaluate(() => window.__survivedTheSwitch === true));
check('the picker arrives without the page being fetched again',
ttyd.pageLoads.length === loadsBefore,
`${ttyd.pageLoads.length} loads, was ${loadsBefore}`);
check('a second socket is opened, with no arg on it',
ttyd.sockets.length === 2 && !ttyd.sockets[1].url.includes('arg='),
JSON.stringify(ttyd.sockets.map((s) => s.url)));
check('the first socket is closed, which is what ends the session left behind',
ttyd.sockets[0].closed === true);
check('the address bar stops naming the session, so a reload lands on the picker',
!(await page.evaluate(() => window.location.search)).includes('arg='),
await page.evaluate(() => window.location.search));
// The session's own output must not still be on screen under the picker: the
// socket carries away the process, not what it left in the terminal.
// Typing has to reach the command the new socket spawned. The terminal's
// handlers were wired to the client once, at startup, and the client is now
// holding a different socket.
await page.click('#terminal');
await page.keyboard.type('2');
await page.waitForFunction(() => true);
await new Promise((r) => setTimeout(r, 200));
check('what is typed after the switch reaches the new socket',
ttyd.sockets[1].input.includes('2') && !ttyd.sockets[0].input.includes('2'),
JSON.stringify(ttyd.sockets.map((s) => s.input)));
check('the terminal is reset, so the picker is not drawn over the session',
!(await page.evaluate(() => document.body.innerText)).includes('SESSION ' + RUN_ID));
await browser.close();
await ttyd.stop();
const failed = checks.filter((c) => !c.ok).length;
console.log(`\n${checks.length - failed}/${checks.length} checks passed`);
process.exit(failed === 0 ? 0 : 1);
+81
View File
@@ -44,3 +44,84 @@ test("the query string rides along, because ttyd's --url-arg reads it off the We
'wss://h/ws'
);
});
// A WebSocket and a fetch just real enough for createClient: every instance is
// recorded, so a test can see which url was opened and what was done to the
// one before it.
function stubTransport() {
const sockets = [];
globalThis.WebSocket = function (url, protocols) {
this.url = url;
this.protocols = protocols;
this.readyState = 0;
this.closed = false;
this.sent = [];
this.send = function (data) { this.sent.push(data); };
this.close = function () { this.closed = true; if (this.onclose) this.onclose(); };
sockets.push(this);
};
globalThis.WebSocket.OPEN = 1;
globalThis.fetch = () => Promise.resolve({ json: () => ({ token: 't' }) });
return sockets;
}
function client(search, statuses) {
return globalThis.DEJIMA.protocol.createClient({
location: { protocol: 'http:', host: 'h', pathname: '/', search },
size: () => ({ cols: 80, rows: 24 }),
onOutput() {}, onTitle() {}, onPreferences() {},
onStatus: (s) => statuses.push(s)
});
}
test('switchTo opens a new socket for a different arg, and keeps the page', async () => {
const sockets = stubTransport();
const statuses = [];
const c = client('?arg=0123456789abcdef', statuses);
c.connect();
await Promise.resolve(); await Promise.resolve();
assert.equal(sockets.length, 1);
assert.equal(sockets[0].url, 'ws://h/ws?arg=0123456789abcdef');
c.switchTo('');
await Promise.resolve(); await Promise.resolve();
// The old one was closed - ttyd kills the command when the socket goes, so
// this is what ends the session being left - and a second one opened on the
// url with no arg, which is the picker.
assert.equal(sockets[0].closed, true);
assert.equal(sockets.length, 2);
assert.equal(sockets[1].url, 'ws://h/ws');
assert.equal(c.urls().ws, 'ws://h/ws');
});
test('a switch is not reported as the session dropping', async () => {
const sockets = stubTransport();
const statuses = [];
const c = client('?arg=0123456789abcdef', statuses);
c.connect();
await Promise.resolve(); await Promise.resolve();
statuses.length = 0;
c.switchTo('');
await Promise.resolve(); await Promise.resolve();
// "closed" is what puts "Disconnected. Reconnecting..." on the screen and
// starts the backoff. A switch is neither.
assert.deepEqual(statuses, ['connecting']);
});
test('a switch while disconnected drops the pending reconnect', async () => {
const sockets = stubTransport();
const statuses = [];
const c = client('?arg=0123456789abcdef', statuses);
c.connect();
await Promise.resolve(); await Promise.resolve();
sockets[0].onclose(); // the session ended; a retry is now queued
const queued = sockets.length;
c.switchTo('');
await Promise.resolve(); await Promise.resolve();
assert.equal(sockets.length, queued + 1);
assert.equal(sockets[sockets.length - 1].url, 'ws://h/ws');
// And the queued retry must not arrive afterwards and reopen the session
// that was just left.
await new Promise((r) => setTimeout(r, 700));
assert.equal(sockets.length, queued + 1);
});