@minimalcorp/tsunagi 0.4.2 → 0.4.3

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.
Files changed (52) hide show
  1. package/.next/standalone/apps/web/.next/BUILD_ID +1 -1
  2. package/.next/standalone/apps/web/.next/build-manifest.json +3 -3
  3. package/.next/standalone/apps/web/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/apps/web/.next/server/app/_global-error.html +1 -1
  5. package/.next/standalone/apps/web/.next/server/app/_global-error.rsc +1 -1
  6. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  9. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  10. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/apps/web/.next/server/app/_not-found.html +1 -1
  12. package/.next/standalone/apps/web/.next/server/app/_not-found.rsc +1 -1
  13. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  14. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  15. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  16. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  17. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  18. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  19. package/.next/standalone/apps/web/.next/server/app/index.html +1 -1
  20. package/.next/standalone/apps/web/.next/server/app/index.rsc +1 -1
  21. package/.next/standalone/apps/web/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  22. package/.next/standalone/apps/web/.next/server/app/index.segments/_full.segment.rsc +1 -1
  23. package/.next/standalone/apps/web/.next/server/app/index.segments/_head.segment.rsc +1 -1
  24. package/.next/standalone/apps/web/.next/server/app/index.segments/_index.segment.rsc +1 -1
  25. package/.next/standalone/apps/web/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  26. package/.next/standalone/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  27. package/.next/standalone/apps/web/.next/server/app/settings.html +1 -1
  28. package/.next/standalone/apps/web/.next/server/app/settings.rsc +2 -2
  29. package/.next/standalone/apps/web/.next/server/app/settings.segments/_full.segment.rsc +2 -2
  30. package/.next/standalone/apps/web/.next/server/app/settings.segments/_head.segment.rsc +1 -1
  31. package/.next/standalone/apps/web/.next/server/app/settings.segments/_index.segment.rsc +1 -1
  32. package/.next/standalone/apps/web/.next/server/app/settings.segments/_tree.segment.rsc +1 -1
  33. package/.next/standalone/apps/web/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
  34. package/.next/standalone/apps/web/.next/server/app/settings.segments/settings.segment.rsc +1 -1
  35. package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  36. package/.next/standalone/apps/web/.next/server/chunks/ssr/_03lm.~7._.js +1 -1
  37. package/.next/standalone/apps/web/.next/server/chunks/ssr/_09br4yx._.js +1 -1
  38. package/.next/standalone/apps/web/.next/server/middleware-build-manifest.js +3 -3
  39. package/.next/standalone/apps/web/.next/server/pages/404.html +1 -1
  40. package/.next/standalone/apps/web/.next/server/pages/500.html +1 -1
  41. package/.next/standalone/apps/web/.next/server/server-reference-manifest.js +1 -1
  42. package/.next/standalone/apps/web/.next/server/server-reference-manifest.json +1 -1
  43. package/.next/standalone/apps/web/.next/static/chunks/{11u7x4ic4~mp3.js → 0t3d~cifmhdv9.js} +1 -1
  44. package/.next/standalone/apps/web/.next/static/chunks/{0ae7zmpe8063z.js → 1662w8cu9n67h.js} +1 -1
  45. package/dist/server/pty-manager.d.ts +28 -2
  46. package/dist/server/pty-manager.js +71 -4
  47. package/dist/server/routes/terminal.js +24 -48
  48. package/npm-shrinkwrap.json +202 -214
  49. package/package.json +1 -1
  50. /package/.next/standalone/apps/web/.next/static/{vdAs-hr87-L7y2bUSdX3i → h5xwYydO3EbBSaceTFcLG}/_buildManifest.js +0 -0
  51. /package/.next/standalone/apps/web/.next/static/{vdAs-hr87-L7y2bUSdX3i → h5xwYydO3EbBSaceTFcLG}/_clientMiddlewareManifest.js +0 -0
  52. /package/.next/standalone/apps/web/.next/static/{vdAs-hr87-L7y2bUSdX3i → h5xwYydO3EbBSaceTFcLG}/_ssgManifest.js +0 -0
@@ -25,9 +25,17 @@ export interface PtySession {
25
25
  respawning: boolean;
26
26
  /** 最後にユーザー入力があった時刻(入力中の作り直しを避けるため) */
27
27
  lastInputAt: number;
28
- /** PTY出力のリングバッファ(再接続時にまとめて送信) */
29
- scrollback: string[];
28
+ /**
29
+ * PTY出力のリングバッファ(再接続時にまとめて送信)。
30
+ * 文字列は PTY 出力、TerminalSize は「ここで PTY がこのサイズに変わった」印。
31
+ * replay 時にサイズの区切りごとに xterm を resize してから書くことで、録画時と同じ幅で再生する
32
+ * (幅の違う出力を一つの幅で再生すると、列指定が切り詰められて崩れる)。
33
+ */
34
+ scrollback: ScrollbackEntry[];
35
+ /** scrollback 中の出力のバイト数(サイズ印は含まない) */
30
36
  scrollbackSize: number;
37
+ /** scrollback の先頭時点の PTY サイズ(最初のサイズ印より前の出力がこの幅で描かれた) */
38
+ scrollbackStartSize: TerminalSize;
31
39
  /** 最後にPTY出力があった時刻(GC判定用) */
32
40
  lastOutputAt: number;
33
41
  /** 最後にWebSocket接続があった時刻 */
@@ -39,6 +47,12 @@ export interface TerminalSize {
39
47
  cols: number;
40
48
  rows: number;
41
49
  }
50
+ /** scrollback の要素。文字列は PTY 出力、TerminalSize は PTY の resize 位置を示す印 */
51
+ export type ScrollbackEntry = string | TerminalSize;
52
+ /** replay 用に scrollback をサイズごとに区切ったもの。data はそのサイズで描かれた出力 */
53
+ export interface ReplaySegment extends TerminalSize {
54
+ data: string;
55
+ }
42
56
  declare class PtyManager {
43
57
  private sessions;
44
58
  /** sessionId → 最後に input を送信した socket.id */
@@ -60,6 +74,18 @@ declare class PtyManager {
60
74
  */
61
75
  createSession(sessionId: string, cwd: string, env?: Record<string, string>, unsetKeys?: string[], options?: PtySessionOptions): PtySession;
62
76
  getSession(sessionId: string): PtySession | undefined;
77
+ /**
78
+ * PTY を resize する。サイズが変わったときだけ SIGWINCH が飛び、scrollback にサイズ印を残す。
79
+ * PTY の resize は必ずここを通し、replay が「どの出力がどの幅で描かれたか」を再現できるようにする。
80
+ * @returns 実際にサイズが変わったか
81
+ */
82
+ resizePty(session: PtySession, size: TerminalSize): boolean;
83
+ /**
84
+ * 再接続時の画面復元用に、scrollback を PTY サイズごとの区間に分ける。
85
+ * 末尾の区間は必ず現在の PTY サイズになる(出力が無くても返す)。
86
+ * @param transform 各区間の出力に適用する加工(端末クエリの除去など。区間ごとに join 済みの文字列を渡す)
87
+ */
88
+ getReplaySegments(session: PtySession, transform: (data: string) => string): ReplaySegment[];
63
89
  deleteSession(sessionId: string): void;
64
90
  /**
65
91
  * WebSocket接続が切れた際に呼ぶ。
@@ -45,6 +45,7 @@ function addTsunagiPluginDir(env) {
45
45
  dirs.push(pluginDir);
46
46
  env.CLAUDE_CODE_PLUGIN_DIRS = dirs.join(':');
47
47
  }
48
+ const INITIAL_PTY_SIZE = { cols: 80, rows: 24 };
48
49
  class PtyManager {
49
50
  sessions = new Map();
50
51
  /** sessionId → 最後に input を送信した socket.id */
@@ -118,8 +119,8 @@ class PtyManager {
118
119
  addTsunagiPluginDir(spawnEnv);
119
120
  const ptyProcess = pty.spawn(shell, [], {
120
121
  name: 'xterm-256color',
121
- cols: 80,
122
- rows: 24,
122
+ cols: INITIAL_PTY_SIZE.cols,
123
+ rows: INITIAL_PTY_SIZE.rows,
123
124
  cwd,
124
125
  env: spawnEnv,
125
126
  });
@@ -136,6 +137,7 @@ class PtyManager {
136
137
  lastInputAt: 0,
137
138
  scrollback: [],
138
139
  scrollbackSize: 0,
140
+ scrollbackStartSize: INITIAL_PTY_SIZE,
139
141
  lastOutputAt: now,
140
142
  lastConnectedAt: now,
141
143
  gcTimer: null,
@@ -147,8 +149,14 @@ class PtyManager {
147
149
  session.scrollbackSize += data.length;
148
150
  // バッファ上限を超えたら古いものから削除
149
151
  while (session.scrollbackSize > SCROLLBACK_MAX_BYTES && session.scrollback.length > 0) {
150
- session.scrollbackSize -= session.scrollback[0].length;
151
- session.scrollback.shift();
152
+ const head = session.scrollback.shift();
153
+ if (typeof head === 'string') {
154
+ session.scrollbackSize -= head.length;
155
+ }
156
+ else {
157
+ // サイズ印を捨てる → 残りの先頭はこのサイズで描かれている
158
+ session.scrollbackStartSize = head;
159
+ }
152
160
  }
153
161
  });
154
162
  this.sessions.set(sessionId, session);
@@ -157,6 +165,65 @@ class PtyManager {
157
165
  getSession(sessionId) {
158
166
  return this.sessions.get(sessionId);
159
167
  }
168
+ /**
169
+ * PTY を resize する。サイズが変わったときだけ SIGWINCH が飛び、scrollback にサイズ印を残す。
170
+ * PTY の resize は必ずここを通し、replay が「どの出力がどの幅で描かれたか」を再現できるようにする。
171
+ * @returns 実際にサイズが変わったか
172
+ */
173
+ resizePty(session, size) {
174
+ const { pty: ptyProcess } = session;
175
+ if (ptyProcess.cols === size.cols && ptyProcess.rows === size.rows)
176
+ return false;
177
+ try {
178
+ ptyProcess.resize(size.cols, size.rows);
179
+ }
180
+ catch {
181
+ // 終了済みの PTY は無視する
182
+ return false;
183
+ }
184
+ // 直前の印以降に出力が無ければ、その印は不要なので置き換える
185
+ // (ウィンドウのドラッグ中など、出力を挟まず resize が連続しても印が溜まらないようにする)
186
+ const last = session.scrollback.at(-1);
187
+ if (last !== undefined && typeof last !== 'string') {
188
+ session.scrollback[session.scrollback.length - 1] = { cols: size.cols, rows: size.rows };
189
+ }
190
+ else {
191
+ session.scrollback.push({ cols: size.cols, rows: size.rows });
192
+ }
193
+ return true;
194
+ }
195
+ /**
196
+ * 再接続時の画面復元用に、scrollback を PTY サイズごとの区間に分ける。
197
+ * 末尾の区間は必ず現在の PTY サイズになる(出力が無くても返す)。
198
+ * @param transform 各区間の出力に適用する加工(端末クエリの除去など。区間ごとに join 済みの文字列を渡す)
199
+ */
200
+ getReplaySegments(session, transform) {
201
+ const segments = [];
202
+ let size = session.scrollbackStartSize;
203
+ let chunks = [];
204
+ const flush = () => {
205
+ if (chunks.length > 0) {
206
+ segments.push({ ...size, data: transform(chunks.join('')) });
207
+ chunks = [];
208
+ }
209
+ };
210
+ for (const entry of session.scrollback) {
211
+ if (typeof entry === 'string') {
212
+ chunks.push(entry);
213
+ }
214
+ else {
215
+ flush();
216
+ size = entry;
217
+ }
218
+ }
219
+ flush();
220
+ const current = { cols: session.pty.cols, rows: session.pty.rows };
221
+ const last = segments.at(-1);
222
+ if (!last || last.cols !== current.cols || last.rows !== current.rows) {
223
+ segments.push({ ...current, data: '' });
224
+ }
225
+ return segments;
226
+ }
160
227
  deleteSession(sessionId) {
161
228
  const session = this.sessions.get(sessionId);
162
229
  if (!session)
@@ -27,27 +27,6 @@ function stripTerminalQueries(s) {
27
27
  .replace(/\x1b\[[?>]?[0-9;]*n/g, '') // DSR: ESC[?6n(主犯) / ESC[6n / ESC[5n など
28
28
  .replace(/\x1b\[[?>=]?[0-9;]*c/g, ''); // DA: ESC[c / ESC[0c / ESC[>c / ESC[=c
29
29
  }
30
- /**
31
- * 表示中のクライアントがいなくなった PTY の cols をこの幅に縮める。
32
- * 一定の幅を下回ると Claude(Ink) が full-frame redraw を行い <Static> を再 emit するという
33
- * 観測に基づくしきい値。元 cols が既にこの値の場合のみ -1 にして必ず cols を変化させる。
34
- *
35
- * 再表示時は replay 後にクライアントが実サイズへ resize するため、必ず cols が変わり
36
- * SIGWINCH が飛んで Claude が現在の画面を描き直す(同一サイズの resize は no-op で
37
- * SIGWINCH が飛ばない)。縮める処理を切断時に済ませておくことで、縮めた時の再描画が
38
- * 再表示より前に scrollback に入り、replay と描き直しの順序がタイミングに依存しない。
39
- */
40
- const REDRAW_BUMP_COLS = 64;
41
- function bumpColsForRedraw(session) {
42
- const { pty: ptyProcess } = session;
43
- const cols = ptyProcess.cols === REDRAW_BUMP_COLS ? REDRAW_BUMP_COLS - 1 : REDRAW_BUMP_COLS;
44
- try {
45
- ptyProcess.resize(cols, ptyProcess.rows);
46
- }
47
- catch {
48
- // 終了済みの PTY は無視する
49
- }
50
- }
51
30
  export async function terminalRoutes(fastify) {
52
31
  const io = fastify.io;
53
32
  // 作成中のセッション(sessionId → 作成処理)。存在確認から PTY 作成までの間に await を挟むため、
@@ -112,20 +91,22 @@ export async function terminalRoutes(fastify) {
112
91
  }
113
92
  ptyManager.setActiveSocket(sessionId, socket.id);
114
93
  const { pty: ptyProcess } = session;
115
- const isReused = session.scrollback.length > 0;
94
+ const isReused = session.scrollbackSize > 0;
116
95
  // リングバッファの内容を一括送信(画面復元)。初回接続・再接続を問わず join のたびに送る。
117
- // クライアントは replay を受けたら画面をクリアし、xterm を PTY と同じサイズにしてから
118
- // 書き込み、完了後に実サイズへ resize する(新規セッションは data が空)。
96
+ // 出力は PTY サイズごとの区間に分けて送り、クライアントは画面をクリアした後、区間ごとに
97
+ // xterm をそのサイズにしてから書き込む(録画時と同じ幅で再生する)。書き終えたら実サイズへ
98
+ // resize し、サイズが変わっていれば SIGWINCH で Claude が現在の画面を描き直す。
99
+ // 切断中に PTY のサイズは変えない(以前は再描画を誘発するため cols を縮めていたが、
100
+ // Claude は resize のたびに履歴全体を再出力するため、狭い幅の履歴コピーが scrollback に
101
+ // 積まれて再表示時に横幅の狭い行や同じ内容の繰り返しが残る原因になっていた)。
119
102
  // 端末クエリ列(ESC[?6n / ESC[c 等)を除去してから replay する。
120
103
  // これを怠ると xterm.js が応答を生成し PTY へ注入され、入力欄に "1;2c64;3R..." が
121
104
  // 勝手に入力される(stripTerminalQueries のコメント参照)。
122
- const buffered = isReused ? stripTerminalQueries(session.scrollback.join('')) : '';
123
- socket.emit('replay', {
124
- // 末尾の \r\n / \n / \r をトリムする。
125
- data: buffered.replace(/[\r\n]+$/, ''),
126
- cols: ptyProcess.cols,
127
- rows: ptyProcess.rows,
128
- });
105
+ const segments = ptyManager.getReplaySegments(session, stripTerminalQueries);
106
+ // 末尾の \r\n / \n / \r をトリムする。
107
+ const lastSegment = segments[segments.length - 1];
108
+ lastSegment.data = lastSegment.data.replace(/[\r\n]+$/, '');
109
+ socket.emit('replay', { segments });
129
110
  // reused の場合、最初の resize まで onData 出力をバッファリング
130
111
  let initialResizeHandled = !isReused;
131
112
  const pendingOutput = [];
@@ -167,7 +148,7 @@ export async function terminalRoutes(fastify) {
167
148
  return;
168
149
  ptyManager.setTerminalSize(sessionId, socket.id, { cols, rows });
169
150
  if (ptyManager.getActiveSocket(sessionId) === socket.id) {
170
- ptyProcess.resize(cols, rows);
151
+ ptyManager.resizePty(session, { cols, rows });
171
152
  }
172
153
  // reused時: 初回 resize 後にバッファリングしていた出力をフラッシュ
173
154
  if (!initialResizeHandled) {
@@ -186,9 +167,7 @@ export async function terminalRoutes(fastify) {
186
167
  return;
187
168
  ptyManager.setTerminalSize(sessionId, socket.id, { cols, rows });
188
169
  ptyManager.setActiveSocket(sessionId, socket.id);
189
- if (ptyProcess.cols !== cols || ptyProcess.rows !== rows) {
190
- ptyProcess.resize(cols, rows);
191
- }
170
+ ptyManager.resizePty(session, { cols, rows });
192
171
  });
193
172
  // input イベント: 所有者 socket からの入力のみ PTY へ書き込む。
194
173
  // ゾンビ/非所有 socket(再接続前の旧 socket・誤バインド socket 等)からの重複 input は
@@ -223,23 +202,16 @@ export async function terminalRoutes(fastify) {
223
202
  const next = ptyManager.removeTerminalSocket(boundSessionId, socket.id);
224
203
  if (wasOwner && next) {
225
204
  // 同じ PTY を表示している socket が残っている → 最後に使われた socket に所有権と
226
- // サイズを引き継ぐ(表示中なので GC も cols の縮小も行わない)
205
+ // サイズを引き継ぐ(表示中なので GC は行わない)
227
206
  ptyManager.setActiveSocket(boundSessionId, next.socketId);
228
207
  const session = ptyManager.getSession(boundSessionId);
229
- try {
230
- session?.pty.resize(next.size.cols, next.size.rows);
231
- }
232
- catch {
233
- // 終了済みの PTY は無視する
234
- }
208
+ if (session)
209
+ ptyManager.resizePty(session, next.size);
235
210
  }
236
211
  else if (wasOwner) {
212
+ // 表示中のクライアントがいなくなっても PTY のサイズはそのまま保つ。
213
+ // 再表示時にサイズが同じなら replay だけで復元され、違えば resize で Claude が描き直す
237
214
  ptyManager.scheduleGc(boundSessionId);
238
- // 次に表示されたとき Claude に描き直させるため、表示中のクライアントがいない間に
239
- // cols を縮めておく(REDRAW_BUMP_COLS 参照)
240
- const session = ptyManager.getSession(boundSessionId);
241
- if (session)
242
- bumpColsForRedraw(session);
243
215
  }
244
216
  }
245
217
  });
@@ -350,11 +322,15 @@ export async function terminalRoutes(fastify) {
350
322
  if (!session) {
351
323
  return reply.status(404).send({ error: 'Session not found' });
352
324
  }
353
- const raw = session.scrollback.join('');
325
+ // サイズ印は <RESIZE cols x rows> として埋め込む
326
+ const raw = session.scrollback
327
+ .map((e) => (typeof e === 'string' ? e : `<RESIZE ${e.cols}x${e.rows}>`))
328
+ .join('');
354
329
  // 制御文字を可視化して返す
355
330
  const visible = raw.replace(/\x1b/g, '<ESC>').replace(/\r/g, '<CR>').replace(/\n/g, '<LF>\n');
356
331
  return reply.send({
357
332
  sessionId,
333
+ startSize: session.scrollbackStartSize,
358
334
  chunks: session.scrollback.length,
359
335
  sizeBytes: session.scrollbackSize,
360
336
  raw: Buffer.from(raw).toString('base64'),