@timqi/pier 0.0.8 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,23 +22,42 @@ export class SessionStateStore {
22
22
  }
23
23
  /** Pin plus the summary Projects needs, atomically in one row. */
24
24
  pin(summary, pinned) {
25
- this.#db.prepare(`INSERT INTO session_state(session_id, pinned, cwd, title, created_at)
26
- VALUES (?, ?, ?, ?, ?)
25
+ // A new session joins a project that already has a place in the list.
26
+ // Unranked it would sort on top — lifting the whole project with it, which
27
+ // is the jump manual order exists to stop.
28
+ const sibling = this.#db.prepare("SELECT project_sort AS rank FROM session_state WHERE cwd = ? AND project_sort IS NOT NULL LIMIT 1").get(summary.cwd);
29
+ this.#db.prepare(`INSERT INTO session_state(session_id, pinned, cwd, title, created_at, project_sort)
30
+ VALUES (?, ?, ?, ?, ?, ?)
27
31
  ON CONFLICT(session_id) DO UPDATE SET
28
32
  pinned = excluded.pinned,
29
33
  cwd = excluded.cwd,
30
34
  title = COALESCE(excluded.title, session_state.title),
31
- created_at = excluded.created_at`).run(summary.id, pinned ? 1 : 0, summary.cwd, summary.title ?? null, summary.createdAt);
35
+ created_at = excluded.created_at,
36
+ project_sort = COALESCE(session_state.project_sort, excluded.project_sort)`).run(summary.id, pinned ? 1 : 0, summary.cwd, summary.title ?? null, summary.createdAt, sibling?.rank ?? null);
37
+ }
38
+ /** One drag = one write of the whole list it reordered: index is the place.
39
+ * `sessions` are ids (a session's place inside its project), `projects` are
40
+ * cwds, whose place is stamped on every session that has that cwd. */
41
+ reorder(order) {
42
+ const bySession = this.#db.prepare("UPDATE session_state SET sort = ? WHERE session_id = ?");
43
+ const byCwd = this.#db.prepare("UPDATE session_state SET project_sort = ? WHERE cwd = ?");
44
+ this.#tx(() => {
45
+ order.sessions?.forEach((id, i) => bySession.run(i, id));
46
+ order.projects?.forEach((cwd, i) => byCwd.run(i, cwd));
47
+ });
32
48
  }
33
49
  /** Project rows only; unlike AgentFactory.list(), this never touches disk. */
34
50
  projects() {
35
- const rows = this.#db.prepare(`SELECT session_id AS id, cwd, title, created_at AS createdAt, unread
51
+ const rows = this.#db.prepare(`SELECT session_id AS id, cwd, title, created_at AS createdAt, unread,
52
+ sort, project_sort AS projectSort
36
53
  FROM session_state
37
54
  WHERE pinned = 1 AND cwd IS NOT NULL AND created_at IS NOT NULL
38
55
  ORDER BY created_at DESC`).all();
39
- return rows.map(({ title, unread, ...row }) => ({
56
+ return rows.map(({ title, unread, sort, projectSort, ...row }) => ({
40
57
  ...row,
41
58
  ...(title ? { title } : {}),
59
+ ...(sort === null ? {} : { sort }),
60
+ ...(projectSort === null ? {} : { projectSort }),
42
61
  unread: unread === 1,
43
62
  }));
44
63
  }
@@ -58,10 +77,16 @@ export class SessionStateStore {
58
77
  const update = this.#db.prepare(`UPDATE session_state SET
59
78
  cwd = ?, title = COALESCE(?, title), created_at = ?
60
79
  WHERE session_id = ?`);
61
- this.#db.exec("BEGIN");
62
- try {
80
+ this.#tx(() => {
63
81
  for (const s of summaries)
64
82
  update.run(s.cwd, s.title ?? null, s.createdAt, s.id);
83
+ });
84
+ }
85
+ /** All-or-nothing: a half-written order is a list nobody arranged. */
86
+ #tx(run) {
87
+ this.#db.exec("BEGIN");
88
+ try {
89
+ run();
65
90
  this.#db.exec("COMMIT");
66
91
  }
67
92
  catch (err) {
@@ -70,8 +95,14 @@ export class SessionStateStore {
70
95
  }
71
96
  }
72
97
  flags() {
73
- const rows = this.#db.prepare("SELECT session_id AS id, pinned, unread FROM session_state WHERE pinned = 1 OR unread = 1").all();
74
- return new Map(rows.map((r) => [r.id, { pinned: r.pinned === 1, unread: r.unread === 1 }]));
98
+ const rows = this.#db.prepare(`SELECT session_id AS id, pinned, unread, sort, project_sort AS projectSort
99
+ FROM session_state WHERE pinned = 1 OR unread = 1`).all();
100
+ return new Map(rows.map((r) => [r.id, {
101
+ pinned: r.pinned === 1,
102
+ unread: r.unread === 1,
103
+ ...(r.sort === null ? {} : { sort: r.sort }),
104
+ ...(r.projectSort === null ? {} : { projectSort: r.projectSort }),
105
+ }]));
75
106
  }
76
107
  /** The first prompt supplies the title of a newly-created pinned session. */
77
108
  title(sessionId, text) {
@@ -28,6 +28,15 @@ const MAX_FRAME_BYTES = 1024 * 1024;
28
28
  // start under. Inheriting these can attach the shell back into Pier's parent
29
29
  // tmux session; SSH_AUTH_SOCK deliberately stays so git/ssh keep working.
30
30
  const PARENT_TERMINAL_ENV = ["TMUX", "TMUX_PANE", "SSH_TTY", "SSH_CLIENT", "SSH_CONNECTION"];
31
+ // Pier's own configuration is Pier's, not this shell's. `NODE_ENV=production`
32
+ // alone turns an `npm i` typed here into an install with no dev dependencies,
33
+ // and every `PI_*`/`PIER_*` would point a `pi` started here at Pier's own
34
+ // instance rather than the person's. Everything else is inherited on purpose:
35
+ // PATH, LANG, SSH_AUTH_SOCK and the session's XDG/DBUS handles are what make
36
+ // the shell usable, and on a service-managed instance nothing else supplies
37
+ // them.
38
+ const PIER_OWN_ENV = ["NODE_ENV", "PORT", "HOST"];
39
+ const PIER_OWN_PREFIX = /^PI(ER)?_/;
31
40
  const send = (sock, data) => {
32
41
  try {
33
42
  sock.send(data);
@@ -44,6 +53,7 @@ export class TerminalHub {
44
53
  #shell;
45
54
  #idleMs;
46
55
  #maxTerms;
56
+ #initCommand;
47
57
  #sweeper;
48
58
  #closeListeners = new Set();
49
59
  #closed = false;
@@ -51,6 +61,7 @@ export class TerminalHub {
51
61
  this.#shell = opts.shell ?? process.env.SHELL ?? "/bin/bash";
52
62
  this.#idleMs = opts.idleMs ?? IDLE_MS;
53
63
  this.#maxTerms = opts.maxTerms ?? MAX_TERMS;
64
+ this.#initCommand = opts.initCommand ?? (() => "");
54
65
  this.#sweeper = setInterval(() => this.sweep(Date.now()), SWEEP_MS);
55
66
  this.#sweeper.unref();
56
67
  }
@@ -117,8 +128,11 @@ export class TerminalHub {
117
128
  }
118
129
  #spawn(cwd) {
119
130
  const env = { ...process.env };
120
- for (const key of PARENT_TERMINAL_ENV)
121
- delete env[key];
131
+ for (const key of Object.keys(env)) {
132
+ if (PARENT_TERMINAL_ENV.includes(key) || PIER_OWN_ENV.includes(key) || PIER_OWN_PREFIX.test(key)) {
133
+ delete env[key];
134
+ }
135
+ }
122
136
  const pty = spawn(this.#shell, [], {
123
137
  name: "xterm-256color",
124
138
  cols: 120,
@@ -129,6 +143,14 @@ export class TerminalHub {
129
143
  const term = { pty, ring: [], ringBytes: 0, clients: new Set(), idleSince: Infinity };
130
144
  this.#terms.set(cwd, term);
131
145
  log.info(`shell ${pty.pid} for ${cwd}`);
146
+ // Typed in, not exec'd: the shell stays the parent, so quitting whatever
147
+ // this starts leaves a usable prompt, and the echo plus any error is in
148
+ // the ring where the person can see what ran. The tty buffers it until the
149
+ // shell's first read, so no wait is needed. A reattach never repeats it —
150
+ // this runs once per pty, which is once per cwd.
151
+ const init = this.#initCommand().trim();
152
+ if (init)
153
+ pty.write(`${init}\r`);
132
154
  pty.onData((data) => {
133
155
  const chunk = Buffer.from(data);
134
156
  term.ring.push(chunk);
@@ -179,6 +201,13 @@ export class TerminalHub {
179
201
  term.pty.write(msg.d);
180
202
  return;
181
203
  }
204
+ // The only way a page can end a shell: everything attached to it is told by
205
+ // the exit path below, exactly as if the shell had exited on its own.
206
+ if (msg.t === "restart") {
207
+ log.info(`shell ${term.pty.pid} for ${cwd} killed on request`);
208
+ term.pty.kill();
209
+ return;
210
+ }
182
211
  if (msg.t === "resize" &&
183
212
  typeof msg.cols === "number" && typeof msg.rows === "number" &&
184
213
  Number.isInteger(msg.cols) && Number.isInteger(msg.rows) &&
@@ -225,8 +254,9 @@ export class TerminalHub {
225
254
  /** The upgrade seam: `/api/terminal?cwd=…` behind the same password boundary
226
255
  * as every route. SameSite=Lax already withholds the cookie cross-site; the
227
256
  * Origin check is the explicit copy of that fact. */
228
- export function attachTerminal(server, auth, heartbeatMs = HEARTBEAT_MS) {
229
- const hub = new TerminalHub();
257
+ export function attachTerminal(server, auth, opts = {}) {
258
+ const heartbeatMs = opts.heartbeatMs ?? HEARTBEAT_MS;
259
+ const hub = new TerminalHub(opts);
230
260
  const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME_BYTES });
231
261
  const alive = new WeakSet();
232
262
  const heartbeat = setInterval(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timqi/pier",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "A self-hosted workspace for coding agents: web workbench and IM channels in front of Pi sessions",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": "github:timqi/pier",